Compare commits
1 Commits
main
...
download-t
Author | SHA1 | Date | |
---|---|---|---|
|
8529c3b403 |
14
.env.example
@ -1,14 +0,0 @@
|
|||||||
ADDON_USERNAME=
|
|
||||||
ADDON_PASSWORD=
|
|
||||||
|
|
||||||
KODI_HOME=tests/home
|
|
||||||
KODI_INTERACTIVE=0
|
|
||||||
KODI_STUB_VERBOSE=1
|
|
||||||
KODI_STUB_RPC_RESPONSES=tests/rpc
|
|
||||||
|
|
||||||
#HTTP_PROXY=
|
|
||||||
#HTTPS_PROXY=
|
|
||||||
|
|
||||||
GH_USERNAME=
|
|
||||||
GH_TOKEN=
|
|
||||||
EMAIL=
|
|
3
.gitattributes
vendored
@ -1,4 +1,3 @@
|
|||||||
.env.example export-ignore
|
|
||||||
.github/ export-ignore
|
.github/ export-ignore
|
||||||
tests/ export-ignore
|
tests/ export-ignore
|
||||||
.gitattributes export-ignore
|
.gitattributes export-ignore
|
||||||
@ -6,4 +5,4 @@ tests/ export-ignore
|
|||||||
.pylintrc export-ignore
|
.pylintrc export-ignore
|
||||||
Makefile export-ignore
|
Makefile export-ignore
|
||||||
requirements.txt export-ignore
|
requirements.txt export-ignore
|
||||||
scripts/ export-ignore
|
tox.ini export-ignore
|
||||||
|
2
.github/FUNDING.yml
vendored
@ -1,2 +0,0 @@
|
|||||||
github: michaelarnauts
|
|
||||||
custom: https://www.buymeacoffee.com/michaelarnauts
|
|
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Logs**
|
|
||||||
Add debug logs to help troubleshoot the issue. See https://kodi.wiki/view/Log_file/Easy for more info.
|
|
||||||
|
|
||||||
**System**
|
|
||||||
- Addon version:
|
|
||||||
- Kodi version:
|
|
||||||
- Inputstream adaptive version:
|
|
||||||
- Operating System (Windows / Mac OS / Android / LibreElec / OSMC / ...):
|
|
||||||
- Special Hardware (RPI / Vero4K+ / ...):
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
35
.github/workflows/addon-check.yml
vendored
@ -1,22 +1,41 @@
|
|||||||
name: Kodi
|
name: Kodi
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Run action when pushed to master, or for commits in a pull request.
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
kodi-addon-checker:
|
tests:
|
||||||
name: Addon checker
|
name: Kodi Add-on checker
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
kodi-branch: [leia, matrix]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
|
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Run kodi-addon-checker
|
|
||||||
uses: xbmc/action-kodi-addon-checker@v1.2
|
|
||||||
with:
|
with:
|
||||||
kodi-version: matrix
|
path: ${{ github.repository }}
|
||||||
addon-id: ${{ github.event.repository.name }}
|
- name: Set up Python 3.8
|
||||||
|
uses: actions/setup-python@v1
|
||||||
|
with:
|
||||||
|
python-version: 3.8
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get install libxml2-utils xmlstarlet
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install kodi-addon-checker
|
||||||
|
- name: Remove unwanted files
|
||||||
|
run: awk '/export-ignore/ { print $1 }' .gitattributes | xargs rm -rf --
|
||||||
|
working-directory: ${{ github.repository }}
|
||||||
|
- name: Rewrite addon.xml for Matrix
|
||||||
|
run: xmlstarlet ed -L -u '/addon/requires/import[@addon="xbmc.python"]/@version' -v "3.0.0" addon.xml
|
||||||
|
working-directory: ${{ github.repository }}
|
||||||
|
if: matrix.kodi-branch == 'matrix'
|
||||||
|
- name: Run kodi-addon-checker
|
||||||
|
run: kodi-addon-checker --branch=${{ matrix.kodi-branch }} ${{ github.repository }}/
|
||||||
|
69
.github/workflows/ci.yml
vendored
@ -1,65 +1,58 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Run action when pushed to master, or for commits in a pull request.
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
name: Add-on testing
|
name: Unit tests
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
PYTHONIOENCODING: utf-8
|
||||||
|
PYTHONPATH: ${{ github.workspace }}/resources/lib:${{ github.workspace }}/tests
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ ubuntu-latest, windows-latest ]
|
python-version: [2.7, 3.5, 3.6, 3.7, 3.8]
|
||||||
python-version: ["3.8", "3.9", "3.10"]
|
|
||||||
include:
|
|
||||||
# End-of-life Python versions are not available anymore with ubuntu-latest
|
|
||||||
- os: ubuntu-20.04
|
|
||||||
python-version: "3.5"
|
|
||||||
- os: ubuntu-20.04
|
|
||||||
python-version: "3.6"
|
|
||||||
- os: ubuntu-20.04
|
|
||||||
python-version: "3.7"
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
|
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v1
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: |
|
||||||
|
sudo apt-get install --no-install-recommends gettext ffmpeg
|
||||||
|
sudo pip install coverage --install-option="--install-scripts=/usr/bin"
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: make check-pylint
|
run: |
|
||||||
|
make check-pylint
|
||||||
|
- name: Run tox
|
||||||
|
run: |
|
||||||
|
make check-tox
|
||||||
- name: Check translations
|
- name: Check translations
|
||||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9'
|
run: |
|
||||||
run: sudo apt-get install gettext && make check-translations
|
make check-translations
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
env:
|
env:
|
||||||
ADDON_USERNAME: ${{ secrets.ADDON_USERNAME }}
|
ADDON_USERNAME: ${{ secrets.ADDON_USERNAME }}
|
||||||
ADDON_PASSWORD: ${{ secrets.ADDON_PASSWORD }}
|
ADDON_PASSWORD: ${{ secrets.ADDON_PASSWORD }}
|
||||||
PYTHONIOENCODING: utf-8
|
run: |
|
||||||
KODI_HOME: ${{ github.workspace }}/tests/home
|
coverage run -m unittest discover
|
||||||
KODI_INTERACTIVE: 0
|
- name: Run addon
|
||||||
KODI_STUB_RPC_RESPONSES: ${{ github.workspace }}/tests/rpc
|
run: |
|
||||||
HTTP_PROXY: ${{ secrets.HTTP_PROXY }}
|
coverage run -a tests/run.py /
|
||||||
run: pytest -x -v --cov=./ --cov-report=xml tests
|
- name: Run add-on service
|
||||||
|
run: |
|
||||||
|
coverage run -a service_entry.py
|
||||||
- name: Upload code coverage to CodeCov
|
- name: Upload code coverage to CodeCov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v1
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
|
||||||
OS: ${{ matrix.os }}
|
|
||||||
PYTHON: ${{ matrix.python-version }}
|
|
||||||
with:
|
|
||||||
flags: unittests
|
|
||||||
env_vars: OS,PYTHON
|
|
||||||
|
34
.github/workflows/release.yml
vendored
@ -1,34 +0,0 @@
|
|||||||
name: Release
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Release plugin.video.viervijfzes
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out ${{ github.sha }} from repository ${{ github.repository }}
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Get changelog
|
|
||||||
id: get-changelog
|
|
||||||
run: |
|
|
||||||
description=$(sed '1,6d;/^## /,$d' CHANGELOG.md)
|
|
||||||
echo $description
|
|
||||||
description="${description//'%'/'%25'}"
|
|
||||||
description="${description//$'\n'/'%0A'}"
|
|
||||||
description="${description//$'\r'/'%0D'}"
|
|
||||||
echo ::set-output name=body::$description
|
|
||||||
|
|
||||||
- name: Generate distribution zips
|
|
||||||
run: scripts/build.py
|
|
||||||
|
|
||||||
- name: Create Release on Github
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
body: ${{ steps.get-changelog.outputs.body }}
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
files: "dist/*.zip"
|
|
||||||
token: ${{ secrets.GH_TOKEN }}
|
|
17
.gitignore
vendored
@ -9,14 +9,13 @@
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
*~
|
*~
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
.coverage
|
.coverage
|
||||||
.tox/
|
.tox/
|
||||||
|
tests/userdata/credentials.json
|
||||||
# Testing
|
tests/userdata/temp
|
||||||
tests/home/userdata/addon_data
|
tests/userdata/token.json
|
||||||
.env
|
tests/userdata/cache
|
||||||
|
tests/userdata/addon_data
|
||||||
Pipfile
|
tests/userdata/tokens
|
||||||
Pipfile.lock
|
tests/cdm
|
||||||
|
|
||||||
dist/
|
|
||||||
|
12
.pylintrc
@ -1,12 +1,11 @@
|
|||||||
[MESSAGES CONTROL]
|
[MESSAGES CONTROL]
|
||||||
disable=
|
disable=
|
||||||
bad-option-value,
|
bad-option-value,
|
||||||
cyclic-import, # This should be fixed
|
cyclic-import, # This shoud be fixed
|
||||||
duplicate-code,
|
duplicate-code,
|
||||||
fixme,
|
fixme,
|
||||||
import-outside-toplevel,
|
import-outside-toplevel,
|
||||||
line-too-long,
|
line-too-long,
|
||||||
no-init,
|
|
||||||
old-style-class,
|
old-style-class,
|
||||||
too-few-public-methods,
|
too-few-public-methods,
|
||||||
too-many-arguments,
|
too-many-arguments,
|
||||||
@ -14,12 +13,3 @@ disable=
|
|||||||
too-many-instance-attributes,
|
too-many-instance-attributes,
|
||||||
too-many-locals,
|
too-many-locals,
|
||||||
too-many-public-methods,
|
too-many-public-methods,
|
||||||
too-many-statements,
|
|
||||||
use-maxsplit-arg,
|
|
||||||
consider-using-from-import,
|
|
||||||
unspecified-encoding,
|
|
||||||
broad-exception-raised,
|
|
||||||
|
|
||||||
super-with-arguments, # Python 2.7 compatibility
|
|
||||||
raise-missing-from, # Python 2.7 compatibility
|
|
||||||
consider-using-f-string, # Python 2.7 compatibility
|
|
||||||
|
179
CHANGELOG.md
@ -1,180 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
[Full Changelog](https://git.jeroened.be/JeroenED/plugin.video.viervijfzes/compare/v0.4.11...v0.4.12)
|
|
||||||
|
|
||||||
**Implemented enhancements:**
|
|
||||||
|
|
||||||
- Add live channels [\#129](https://github.com/add-ons/plugin.video.viervijfzes/pull/129) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
- Add PlayCrime Channel
|
|
||||||
|
|
||||||
|
|
||||||
## [v0.4.11](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.11) (2023-07-26)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.10...v0.4.11)
|
|
||||||
|
|
||||||
**Implemented enhancements:**
|
|
||||||
|
|
||||||
- Add support for proxies [\#126](https://github.com/add-ons/plugin.video.viervijfzes/pull/126) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Add DRM support for all streams [\#121](https://github.com/add-ons/plugin.video.viervijfzes/pull/121) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
- Use inputstreamhelper for unprotected MPEG-DASH [\#118](https://github.com/add-ons/plugin.video.viervijfzes/pull/118) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
|
|
||||||
## [v0.4.10](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.10) (2023-01-16)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.9...v0.4.10)
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Update api [\#114](https://github.com/add-ons/plugin.video.viervijfzes/pull/114) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
|
|
||||||
## [v0.4.9](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.9) (2023-01-04)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.8...v0.4.9)
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Add support for unprotected MPEG-DASH streams [\#111](https://github.com/add-ons/plugin.video.viervijfzes/pull/111) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
- Fix clips [\#108](https://github.com/add-ons/plugin.video.viervijfzes/pull/108) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.4.8](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.8) (2022-07-07)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.7...v0.4.8)
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Fix API [\#105](https://github.com/add-ons/plugin.video.viervijfzes/pull/105) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.4.7](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.7) (2022-02-04)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.6...v0.4.7)
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Fix empty My List due to unknown items [\#102](https://github.com/add-ons/plugin.video.viervijfzes/pull/102) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.4.6](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.6) (2022-02-02)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.5...v0.4.6)
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Fix playback of DRM protected content [\#101](https://github.com/add-ons/plugin.video.viervijfzes/pull/101) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.4.5](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.5) (2021-10-21)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.4...v0.4.5)
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Remove dependency on inputstream.adaptive [\#98](https://github.com/add-ons/plugin.video.viervijfzes/pull/98) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Various fixes due to layout changes [\#97](https://github.com/add-ons/plugin.video.viervijfzes/pull/97) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.4.4](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.4) (2021-09-15)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.3...v0.4.4)
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Fix menu [\#93](https://github.com/add-ons/plugin.video.viervijfzes/pull/93) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
|
|
||||||
## [v0.4.3](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.3) (2021-04-24)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.2...v0.4.3)
|
|
||||||
|
|
||||||
**Implemented enhancements:**
|
|
||||||
|
|
||||||
- Fetch a week of EPG data for IPTV Manager [\#89](https://github.com/add-ons/plugin.video.viervijfzes/pull/89) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Improve playback error handling [\#87](https://github.com/add-ons/plugin.video.viervijfzes/pull/87) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Add support for Play7 [\#86](https://github.com/add-ons/plugin.video.viervijfzes/pull/86) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.4.2](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.2) (2021-03-22)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.1...v0.4.2)
|
|
||||||
|
|
||||||
**Implemented enhancements:**
|
|
||||||
|
|
||||||
- Improve artwork and descriptions [\#79](https://github.com/add-ons/plugin.video.viervijfzes/pull/79) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Don't use cached episode info for playback [\#82](https://github.com/add-ons/plugin.video.viervijfzes/pull/82) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.4.1](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.1) (2021-02-27)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.4.0...v0.4.1)
|
|
||||||
|
|
||||||
**Implemented enhancements:**
|
|
||||||
|
|
||||||
- Add recommendations and categories, allow to hide unavailable programs [\#76](https://github.com/add-ons/plugin.video.viervijfzes/pull/76) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Fix incomplete descriptions [\#75](https://github.com/add-ons/plugin.video.viervijfzes/pull/75) ([dagwieers](https://github.com/dagwieers))
|
|
||||||
- Remove background metadata downloading [\#74](https://github.com/add-ons/plugin.video.viervijfzes/pull/74) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Implement My List [\#71](https://github.com/add-ons/plugin.video.viervijfzes/pull/71) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Fix error when requesting a My List that has not been created yet. [\#73](https://github.com/add-ons/plugin.video.viervijfzes/pull/73) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.4.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.4.0) (2021-02-04)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.3.1...v0.4.0)
|
|
||||||
|
|
||||||
**Implemented enhancements:**
|
|
||||||
|
|
||||||
- Rebranding to GoPlay [\#64](https://github.com/add-ons/plugin.video.viervijfzes/pull/64) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.3.1](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.3.1) (2020-11-28)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.3.0...v0.3.1)
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Fix authentication on some older Android devices [\#58](https://github.com/add-ons/plugin.video.viervijfzes/pull/58) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.3.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.3.0) (2020-11-17)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.2.0...v0.3.0)
|
|
||||||
|
|
||||||
**Implemented enhancements:**
|
|
||||||
|
|
||||||
- Add inputstreamhelper [\#57](https://github.com/add-ons/plugin.video.viervijfzes/pull/57) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
- Allow to install and run Kodi Logfile Uploader from the settings [\#50](https://github.com/add-ons/plugin.video.viervijfzes/pull/50) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Allow to install IPTV Manager from the settings [\#49](https://github.com/add-ons/plugin.video.viervijfzes/pull/49) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Allow playing drm protected content [\#47](https://github.com/add-ons/plugin.video.viervijfzes/pull/47) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Fix clearing local metadata [\#55](https://github.com/add-ons/plugin.video.viervijfzes/pull/55) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
- Fix EPG due to parsing issue of the season [\#54](https://github.com/add-ons/plugin.video.viervijfzes/pull/54) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Fix logging for Kodi Matrix [\#48](https://github.com/add-ons/plugin.video.viervijfzes/pull/48) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Opening some programs without a title could throw an error [\#45](https://github.com/add-ons/plugin.video.viervijfzes/pull/45) ([dagwieers](https://github.com/dagwieers))
|
|
||||||
- Show message when Kodi Player fails to get the stream [\#40](https://github.com/add-ons/plugin.video.viervijfzes/pull/40) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
|
|
||||||
## [v0.2.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.2.0) (2020-06-19)
|
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/v0.1.0...v0.2.0)
|
|
||||||
|
|
||||||
**Implemented enhancements:**
|
|
||||||
|
|
||||||
- Add IPTV Manager to settings [\#35](https://github.com/add-ons/plugin.video.viervijfzes/pull/35) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Add "Clean Metadata" setting [\#34](https://github.com/add-ons/plugin.video.viervijfzes/pull/34) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Pass genre, program title and episode number to IPTV Manager [\#31](https://github.com/add-ons/plugin.video.viervijfzes/pull/31) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Add stream info for direct playing in Kodi 19 [\#30](https://github.com/add-ons/plugin.video.viervijfzes/pull/30) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Add support for playing from the Guide with IPTV Manager [\#29](https://github.com/add-ons/plugin.video.viervijfzes/pull/29) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Add option for debug logging and fix CI [\#27](https://github.com/add-ons/plugin.video.viervijfzes/pull/27) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Use inputstream.adaptive for playback [\#25](https://github.com/add-ons/plugin.video.viervijfzes/pull/25) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Add categories and clips [\#23](https://github.com/add-ons/plugin.video.viervijfzes/pull/23) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Add compatibility for Kodi 19 Python API [\#20](https://github.com/add-ons/plugin.video.viervijfzes/pull/20) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
- Py2/3 compatibility fixes [\#16](https://github.com/add-ons/plugin.video.viervijfzes/pull/16) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
- Rework cache [\#15](https://github.com/add-ons/plugin.video.viervijfzes/pull/15) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
**Fixed bugs:**
|
|
||||||
|
|
||||||
- Fix data transfers over 1 MB to IPTV Manager [\#32](https://github.com/add-ons/plugin.video.viervijfzes/pull/32) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
- Fix multi-line text in progress dialog [\#21](https://github.com/add-ons/plugin.video.viervijfzes/pull/21) ([mediaminister](https://github.com/mediaminister))
|
|
||||||
- Fix token encoding in auth [\#19](https://github.com/add-ons/plugin.video.viervijfzes/pull/19) ([michaelarnauts](https://github.com/michaelarnauts))
|
|
||||||
|
|
||||||
## [v0.1.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.1.0) (2020-03-27)
|
## [v0.1.0](https://github.com/add-ons/plugin.video.viervijfzes/tree/v0.1.0) (2020-03-27)
|
||||||
|
|
||||||
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/89f55f70b017d0add645d1e1d88f0ce8192d11c4...v0.1.0)
|
[Full Changelog](https://github.com/add-ons/plugin.video.viervijfzes/compare/89f55f70b017d0add645d1e1d88f0ce8192d11c4...v0.1.0)
|
||||||
@ -190,7 +15,11 @@
|
|||||||
|
|
||||||
**Merged pull requests:**
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- Improve CI tests [\#14](https://github.com/add-ons/plugin.video.viervijfzes/pull/14) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||||
- Small translation fixes [\#12](https://github.com/add-ons/plugin.video.viervijfzes/pull/12) ([dagwieers](https://github.com/dagwieers))
|
- Small translation fixes [\#12](https://github.com/add-ons/plugin.video.viervijfzes/pull/12) ([dagwieers](https://github.com/dagwieers))
|
||||||
|
- Various check fixes [\#11](https://github.com/add-ons/plugin.video.viervijfzes/pull/11) ([dagwieers](https://github.com/dagwieers))
|
||||||
|
- Replace Travis with GitHub Actions [\#10](https://github.com/add-ons/plugin.video.viervijfzes/pull/10) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||||
|
- Improve code coverage [\#9](https://github.com/add-ons/plugin.video.viervijfzes/pull/9) ([michaelarnauts](https://github.com/michaelarnauts))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
14
LICENSE
@ -1,7 +1,7 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
of this license document, but changing it is not allowed.
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
|
|||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
{one line to give the program's name and a brief idea of what it does.}
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) {year} {name of author}
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
@ -645,14 +645,14 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
GNU General Public License for more details.
|
GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If the program does terminal interaction, make it output a short
|
||||||
notice like this when it starts in an interactive mode:
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
{project} Copyright (C) {year} {fullname}
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it
|
This is free software, and you are welcome to redistribute it
|
||||||
under certain conditions; type `show c' for details.
|
under certain conditions; type `show c' for details.
|
||||||
@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
|
|||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
The GNU General Public License does not permit incorporating your program
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
may consider it more useful to permit linking proprietary applications with
|
may consider it more useful to permit linking proprietary applications with
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
Public License instead of this License. But first, please read
|
Public License instead of this License. But first, please read
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
|
73
Makefile
@ -1,69 +1,62 @@
|
|||||||
export KODI_HOME := $(CURDIR)/tests/home
|
export PYTHONPATH := $(CURDIR):$(CURDIR)/tests
|
||||||
export KODI_INTERACTIVE := 0
|
|
||||||
PYTHON := python
|
PYTHON := python
|
||||||
|
|
||||||
|
# Collect information to build as sensible package name
|
||||||
|
name = $(shell xmllint --xpath 'string(/addon/@id)' addon.xml)
|
||||||
|
version = $(shell xmllint --xpath 'string(/addon/@version)' addon.xml)
|
||||||
|
git_branch = $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
|
git_hash = $(shell git rev-parse --short HEAD)
|
||||||
|
zip_name = $(name)-$(version)-$(git_branch)-$(git_hash).zip
|
||||||
|
include_files = addon_entry.py addon.xml CHANGELOG.md LICENSE README.md resources/ service_entry.py
|
||||||
|
include_paths = $(patsubst %,$(name)/%,$(include_files))
|
||||||
|
exclude_files = \*.new \*.orig \*.pyc \*.pyo
|
||||||
|
|
||||||
languages = $(filter-out en_gb, $(patsubst resources/language/resource.language.%, %, $(wildcard resources/language/*)))
|
languages = $(filter-out en_gb, $(patsubst resources/language/resource.language.%, %, $(wildcard resources/language/*)))
|
||||||
|
|
||||||
all: check test build
|
all: check test build
|
||||||
zip: build
|
zip: build
|
||||||
multizip: build
|
|
||||||
|
|
||||||
check: check-pylint check-translations
|
check: check-pylint check-tox check-translations
|
||||||
|
|
||||||
check-pylint:
|
check-pylint:
|
||||||
@printf ">>> Running pylint checks\n"
|
@echo ">>> Running pylint checks"
|
||||||
@$(PYTHON) -m pylint *.py resources/lib/ tests/
|
@$(PYTHON) -m pylint *.py resources/lib/ tests/
|
||||||
|
|
||||||
|
check-tox:
|
||||||
|
@echo ">>> Running tox checks"
|
||||||
|
@$(PYTHON) -m tox -q
|
||||||
|
|
||||||
check-translations:
|
check-translations:
|
||||||
@printf ">>> Running translation checks\n"
|
@echo ">>> Running translation checks"
|
||||||
@$(foreach lang,$(languages), \
|
@$(foreach lang,$(languages), \
|
||||||
msgcmp --use-untranslated resources/language/resource.language.$(lang)/strings.po resources/language/resource.language.en_gb/strings.po; \
|
msgcmp resources/language/resource.language.$(lang)/strings.po resources/language/resource.language.en_gb/strings.po; \
|
||||||
)
|
)
|
||||||
@scripts/check_for_unused_translations.py
|
|
||||||
|
|
||||||
check-addon: build
|
check-addon: clean build
|
||||||
@printf ">>> Running addon checks\n"
|
@echo ">>> Running addon checks"
|
||||||
$(eval TMPDIR := $(shell mktemp -d))
|
$(eval TMPDIR := $(shell mktemp -d))
|
||||||
@unzip dist/plugin.video.viervijfzes-*+matrix.1.zip -d ${TMPDIR}
|
@unzip ../${zip_name} -d ${TMPDIR}
|
||||||
cd ${TMPDIR} && kodi-addon-checker --branch=matrix
|
cd ${TMPDIR} && kodi-addon-checker --branch=leia
|
||||||
@rm -rf ${TMPDIR}
|
@rm -rf ${TMPDIR}
|
||||||
|
|
||||||
codefix:
|
|
||||||
@isort -l 160 resources/
|
|
||||||
|
|
||||||
test: test-unit
|
test: test-unit
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
@printf ">>> Running unit tests\n"
|
@echo ">>> Running unit tests"
|
||||||
@$(PYTHON) -m pytest tests
|
@$(PYTHON) -m unittest discover -v -b -f
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@printf ">>> Cleaning up\n"
|
|
||||||
@find . -name '*.py[cod]' -type f -delete
|
@find . -name '*.py[cod]' -type f -delete
|
||||||
@find . -name '__pycache__' -type d -delete
|
@find . -name '__pycache__' -type d -delete
|
||||||
@rm -rf .pytest_cache/ tests/cdm tests/userdata/temp
|
@rm -rf .pytest_cache/ .tox/ tests/cdm tests/userdata/temp
|
||||||
@rm -f *.log .coverage
|
@rm -f *.log .coverage
|
||||||
@rm -rf dist/
|
|
||||||
|
|
||||||
build: clean
|
build: clean
|
||||||
@printf ">>> Building add-on\n"
|
@echo ">>> Building package"
|
||||||
@scripts/build.py
|
@rm -f ../$(zip_name)
|
||||||
@ls -lah dist/*.zip
|
cd ..; zip -r $(zip_name) $(include_paths) -x $(exclude_files)
|
||||||
|
@echo "Successfully wrote package as: ../$(zip_name)"
|
||||||
|
|
||||||
release:
|
release: build
|
||||||
ifneq ($(release),)
|
rm -rf ../repo-plugins/$(name)/*
|
||||||
docker run -it --rm --env CHANGELOG_GITHUB_TOKEN=$(GH_TOKEN) -v "$(shell pwd)":/usr/local/src/your-app githubchangeloggenerator/github-changelog-generator -u add-ons -p plugin.video.viervijfzes --no-issues --exclude-labels duplicate,question,invalid,wontfix,release,testing --future-release v$(release)
|
unzip ../$(zip_name) -d ../repo-plugins/
|
||||||
|
|
||||||
@printf "cd /addon/@version\nset $$release\nsave\nbye\n" | xmllint --shell addon.xml; \
|
|
||||||
date=$(shell date '+%Y-%m-%d'); \
|
|
||||||
printf "cd /addon/extension[@point='xbmc.addon.metadata']/news\nset v$$release ($$date)\nsave\nbye\n" | xmllint --shell addon.xml; \
|
|
||||||
|
|
||||||
# Next steps to release:
|
|
||||||
# - Modify the news-section of addons.xml
|
|
||||||
# - git add . && git commit -m "Prepare for v$(release)" && git push
|
|
||||||
# - git tag v$(release) && git push --tags
|
|
||||||
else
|
|
||||||
@printf "Usage: make release release=1.0.0\n"
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: check codefix test clean build release
|
|
||||||
|
22
README.md
@ -1,24 +1,28 @@
|
|||||||
[![GitHub release](https://img.shields.io/github/v/release/add-ons/plugin.video.viervijfzes?display_name=tag)](https://github.com/add-ons/plugin.video.viervijfzes/releases)
|
[![GitHub release](https://img.shields.io/github/release/add-ons/plugin.video.viervijfzes.svg?include_prereleases)](https://github.com/add-ons/plugin.video.viervijfzes/releases)
|
||||||
[![Build Status](https://img.shields.io/github/actions/workflow/status/add-ons/plugin.video.viervijfzes/ci.yml?branch=master)](https://github.com/add-ons/plugin.video.viervijfzes/actions?query=branch%3Amaster)
|
[![Build Status](https://img.shields.io/github/workflow/status/add-ons/plugin.video.viervijfzes/CI/master)](https://github.com/add-ons/plugin.video.viervijfzes/actions?query=branch%3Amaster)
|
||||||
[![Codecov status](https://img.shields.io/codecov/c/github/add-ons/plugin.video.viervijfzes/master)](https://codecov.io/gh/add-ons/plugin.video.viervijfzes/branch/master)
|
[![Codecov status](https://img.shields.io/codecov/c/github/add-ons/plugin.video.viervijfzes/master)](https://codecov.io/gh/add-ons/plugin.video.viervijfzes/branch/master)
|
||||||
[![License: GPLv3](https://img.shields.io/badge/License-GPLv3-yellow.svg)](https://opensource.org/licenses/GPL-3.0)
|
[![License: GPLv3](https://img.shields.io/badge/License-GPLv3-yellow.svg)](https://opensource.org/licenses/GPL-3.0)
|
||||||
[![Contributors](https://img.shields.io/github/contributors/add-ons/plugin.video.viervijfzes.svg)](https://github.com/add-ons/plugin.video.viervijfzes/graphs/contributors)
|
[![Contributors](https://img.shields.io/github/contributors/add-ons/plugin.video.viervijfzes.svg)](https://github.com/add-ons/plugin.video.viervijfzes/graphs/contributors)
|
||||||
|
|
||||||
# GoPlay Kodi add-on
|
# VIER-VIJF-ZES Kodi add-on
|
||||||
|
|
||||||
*plugin.video.viervijfzes* is een Kodi add-on om de video-on-demand content van [GoPlay](https://www.goplay.be/) te bekijken. Je moet hiervoor wel eerst een
|
*plugin.video.viervijfzes* is een Kodi add-on om de video-on-demand content van [vier.be](https://www.vier.be/), [vijf.be](https://www.vijf.be/) en [zestv.be](https://www.zestv.be/) te bekijken.
|
||||||
account aanmaken op [goplay.be](https://www.goplay.be/).
|
|
||||||
|
|
||||||
Meer informatie kan je vinden op de [Wiki pagina](https://github.com/add-ons/plugin.video.viervijfzes/wiki).
|
> Note: Je moet eerst een account aanmaken op één van bovenstaande websites.
|
||||||
|
|
||||||
|
## Installatie
|
||||||
|
|
||||||
|
Deze addon staat momenteel nog niet de repository van Kodi zelf, je moet deze voorlopig nog handmatig installeren en updaten.
|
||||||
|
|
||||||
|
Je kan de [laatste release](https://github.com/add-ons/plugin.video.viervijfzes/releases) downloaden, of een [development zip](https://github.com/add-ons/plugin.video.viervijfzes/archive/master.zip) van Github downloaden met de laatste wijzigingen.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
De volgende features worden ondersteund:
|
De volgende features worden ondersteund:
|
||||||
* Bekijk on-demand content van Play4, Play5, Play6 en Play7
|
* Bekijk on-demand content van VIER, VIJF en ZES
|
||||||
* Programma's rechtstreeks afspelen vanuit de tv-gids
|
* Programma's rechtstreeks afspelen vanuit de tv-gids
|
||||||
* Doorzoeken van alle programma's
|
* Doorzoeken van alle programma's
|
||||||
* Afspelen van gerelateerde Youtube content
|
* Afspelen van gerelateerde Youtube content
|
||||||
* Integratie met [IPTV Manager](https://github.com/add-ons/service.iptv.manager)
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@ -33,4 +37,4 @@ De volgende features worden ondersteund:
|
|||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
Deze add-on wordt niet ondersteund door SBS Belgium, en wordt aangeboden 'as is', zonder enige garantie.
|
Deze add-on wordt niet ondersteund door SBS Belgium, en wordt aangeboden 'as is', zonder enige garantie.
|
||||||
De logo's van GoPlay, Play4, Play5, Play6 en Play7 zijn eigendom van SBS België.
|
De logo's van VIER, VIJF en ZES zijn eigendom van SBS België.
|
||||||
|
25
addon.xml
@ -1,9 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<addon id="plugin.video.viervijfzes" name="GoPlay" version="0.4.12" provider-name="Michaël Arnauts">
|
<addon id="plugin.video.viervijfzes" name="VIER-VIJF-ZES" version="0.1.0" provider-name="Michaël Arnauts">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="2.26.0"/>
|
||||||
<import addon="script.module.dateutil" version="2.6.0"/>
|
<import addon="script.module.dateutil" version="2.6.0"/>
|
||||||
<import addon="script.module.inputstreamhelper" version="0.5.1"/>
|
|
||||||
<import addon="script.module.pysocks" version="1.6.8" optional="true"/>
|
<import addon="script.module.pysocks" version="1.6.8" optional="true"/>
|
||||||
<import addon="script.module.requests" version="2.22.0"/>
|
<import addon="script.module.requests" version="2.22.0"/>
|
||||||
<import addon="script.module.routing" version="0.2.0"/>
|
<import addon="script.module.routing" version="0.2.0"/>
|
||||||
@ -13,17 +12,17 @@
|
|||||||
</extension>
|
</extension>
|
||||||
<extension point="xbmc.service" library="service_entry.py"/>
|
<extension point="xbmc.service" library="service_entry.py"/>
|
||||||
<extension point="xbmc.addon.metadata">
|
<extension point="xbmc.addon.metadata">
|
||||||
<summary lang="nl_NL">Bekijk programma's van Play4, Play5 en Play6.</summary>
|
<summary lang="nl_NL">Bekijk programma's van VIER, VIJF en ZES.</summary>
|
||||||
<description lang="nl_NL">Deze add-on geeft toegang tot de programma's die aangeboden worden op de websites van Play4, Play5 en Play6.</description>
|
<description lang="nl_NL">Deze add-on geeft toegang tot de programma's die aangeboden worden op de websites van VIER, VIJF en ZES.</description>
|
||||||
<disclaimer lang="nl_NL">Deze add-on wordt niet ondersteund door SBS België, en wordt aangeboden 'as is', zonder enige garantie. De logo's van Play4, Play5 en Play6 zijn eigendom van SBS België.</disclaimer>
|
<disclaimer lang="nl_NL">Deze add-on wordt niet ondersteund door SBS België, en wordt aangeboden 'as is', zonder enige garantie. De logo's van VIER, VIJF en ZES zijn eigendom van SBS België.</disclaimer>
|
||||||
<summary lang="en_GB">Watch content from Play4, Play5 and Play6.</summary>
|
<summary lang="en_GB">Watch content from VIER, VIJF and ZES.</summary>
|
||||||
<description lang="en_GB">This add-on gives access to video-on-demand content available on the websites of Play4, Play5 and Play6.</description>
|
<description lang="en_GB">This add-on gives access to video-on-demand content available on the websites of VIER, VIJF and ZES.</description>
|
||||||
<disclaimer lang="en_GB">This add-on is not officially commissioned/supported by SBS Belgium and is provided 'as is' without any warranty of any kind. The Play4, Play5 and Play6 logos are property of SBS Belgium.</disclaimer>
|
<disclaimer lang="en_GB">This add-on is not officially commissioned/supported by SBS Belgium and is provided 'as is' without any warranty of any kind. The VIER, VIJF and ZES logos are property of SBS Belgium.</disclaimer>
|
||||||
<platform>all</platform>
|
<platform>all</platform>
|
||||||
<license>GPL-3.0-only</license>
|
<license>GPL-3.0</license>
|
||||||
<news>v0.4.12 (2024-05-08)
|
<news>v0.1.0
|
||||||
- Added live channels (by mediaminister)
|
- First release
|
||||||
- Added PlayCrime Channel</news>
|
</news>
|
||||||
<source>https://github.com/add-ons/plugin.video.viervijfzes</source>
|
<source>https://github.com/add-ons/plugin.video.viervijfzes</source>
|
||||||
<assets>
|
<assets>
|
||||||
<icon>resources/icon.png</icon>
|
<icon>resources/icon.png</icon>
|
||||||
|
@ -3,13 +3,11 @@
|
|||||||
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
from xbmcaddon import Addon
|
import xbmcaddon
|
||||||
|
|
||||||
from resources.lib import kodiutils, kodilogging
|
from resources.lib import kodiutils
|
||||||
|
|
||||||
# Reinitialise ADDON every invocation to fix an issue that settings are not fresh.
|
kodiutils.ADDON = xbmcaddon.Addon()
|
||||||
kodiutils.ADDON = Addon()
|
|
||||||
kodilogging.ADDON = Addon()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
from sys import argv
|
from sys import argv
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
|
coverage
|
||||||
|
git+git://github.com/emilsvennesson/script.module.inputstreamhelper.git@master#egg=inputstreamhelper
|
||||||
polib
|
polib
|
||||||
pylint
|
pylint
|
||||||
pytest
|
|
||||||
pytest-cov
|
|
||||||
pytest-timeout
|
|
||||||
python-dateutil
|
python-dateutil
|
||||||
pysocks
|
|
||||||
requests
|
requests
|
||||||
git+https://github.com/tamland/kodi-plugin-routing@master#egg=routing
|
git+git://github.com/dagwieers/kodi-plugin-routing.git@setup#egg=routing
|
||||||
|
tox
|
||||||
six
|
six
|
||||||
sakee
|
|
||||||
win-inet-pton; platform_system=="Windows"
|
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 14 KiB |
@ -19,15 +19,7 @@ msgid "Catalogue"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30004"
|
msgctxt "#30004"
|
||||||
msgid "Browse the catalogue"
|
msgid "TV Shows and Movies listed by category"
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30005"
|
|
||||||
msgid "Recommendations"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30006"
|
|
||||||
msgid "Show the recommendations"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30007"
|
msgctxt "#30007"
|
||||||
@ -46,24 +38,16 @@ msgctxt "#30010"
|
|||||||
msgid "Search trough the catalogue"
|
msgid "Search trough the catalogue"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30011"
|
|
||||||
msgid "My List"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30012"
|
|
||||||
msgid "Browse My List"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30013"
|
msgctxt "#30013"
|
||||||
msgid "TV guide"
|
msgid "TV guide"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30014"
|
||||||
### SUBMENUS
|
msgid "Browse the TV Guide"
|
||||||
msgctxt "#30052"
|
|
||||||
msgid "Watch live [B]{channel}[/B]"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
||||||
|
### SUBMENUS
|
||||||
msgctxt "#30053"
|
msgctxt "#30053"
|
||||||
msgid "TV Guide for [B]{channel}[/B]"
|
msgid "TV Guide for [B]{channel}[/B]"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -80,28 +64,16 @@ msgctxt "#30056"
|
|||||||
msgid "Browse the Catalog for [B]{channel}[/B]"
|
msgid "Browse the Catalog for [B]{channel}[/B]"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30059"
|
|
||||||
msgid "Clips of [B]{program}[/B]"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30060"
|
|
||||||
msgid "Watch short clips of [B]{program}[/B]"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
|
|
||||||
### CONTEXT MENU
|
### CONTEXT MENU
|
||||||
msgctxt "#30100"
|
|
||||||
msgid "Add to My List"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30101"
|
|
||||||
msgid "Remove from My List"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30102"
|
msgctxt "#30102"
|
||||||
msgid "Go to Program"
|
msgid "Go to Program"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30103"
|
||||||
|
msgid "Download to cache"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
|
||||||
### CODE
|
### CODE
|
||||||
msgctxt "#30204"
|
msgctxt "#30204"
|
||||||
@ -140,10 +112,18 @@ msgctxt "#30702"
|
|||||||
msgid "An error occurred while authenticating: {error}."
|
msgid "An error occurred while authenticating: {error}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30709"
|
||||||
|
msgid "Geo-blocked video"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30710"
|
msgctxt "#30710"
|
||||||
msgid "This video is geo-blocked and can't be played from your location."
|
msgid "This video is geo-blocked and can't be played from your location."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30711"
|
||||||
|
msgid "Unavailable video"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30712"
|
msgctxt "#30712"
|
||||||
msgid "The video is unavailable and can't be played right now."
|
msgid "The video is unavailable and can't be played right now."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@ -152,20 +132,56 @@ msgctxt "#30713"
|
|||||||
msgid "The requested video was not found in the guide."
|
msgid "The requested video was not found in the guide."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30714"
|
||||||
|
msgid "Local metadata is cleared."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30715"
|
||||||
|
msgid "Updating metadata"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30716"
|
||||||
|
msgid "Updating metadata ({index}/{total})..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30717"
|
msgctxt "#30717"
|
||||||
msgid "This program is not available in the catalogue."
|
msgid "This program is not available in the catalogue."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30718"
|
msgctxt "#30718"
|
||||||
msgid "There is no live stream available for {channel}."
|
msgid "Could not cache this episode since the cache folder is not set or does not exist."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30719"
|
msgctxt "#30719"
|
||||||
msgid "This video cannot be played."
|
msgid "Could not cache this episode since ffmpeg seems to be unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30720"
|
msgctxt "#30720"
|
||||||
msgid "This video is not available abroad."
|
msgid "This episode is cached locally. Do you want to play from cache or stream it?"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30721"
|
||||||
|
msgid "Stream"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30722"
|
||||||
|
msgid "Play from cache"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30723"
|
||||||
|
msgid "Starting download..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30724"
|
||||||
|
msgid "Downloading... ({amount}%)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30725"
|
||||||
|
msgid "Download has finished. You can now play this episode from cache."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30726"
|
||||||
|
msgid "This episode is already cached. Do you want to download it again?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
||||||
@ -178,11 +194,11 @@ msgctxt "#30801"
|
|||||||
msgid "Credentials"
|
msgid "Credentials"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30802"
|
msgctxt "#30803"
|
||||||
msgid "Email address"
|
msgid "Email address"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30803"
|
msgctxt "#30805"
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -190,46 +206,26 @@ msgctxt "#30820"
|
|||||||
msgid "Interface"
|
msgid "Interface"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30821"
|
msgctxt "#30827"
|
||||||
msgid "Show unavailable programs"
|
msgid "Metadata"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30829"
|
||||||
|
msgid "Periodically refresh metadata in the background"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgctxt "#30831"
|
||||||
|
msgid "Update local metadata now"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30840"
|
msgctxt "#30840"
|
||||||
msgid "Integration"
|
msgid "Playback from cache"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30841"
|
msgctxt "#30841"
|
||||||
msgid "IPTV Manager"
|
msgid "Allow to download episodes to a cache"
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30842"
|
|
||||||
msgid "Install IPTV Manager add-on…"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30843"
|
msgctxt "#30843"
|
||||||
msgid "Enable IPTV Manager integration"
|
msgid "Select the folder for the cached episodes"
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30844"
|
|
||||||
msgid "IPTV Manager settings…"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30880"
|
|
||||||
msgid "Expert"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30881"
|
|
||||||
msgid "Logging"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30882"
|
|
||||||
msgid "Enable debug logging"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30883"
|
|
||||||
msgid "Install Kodi Logfile Uploader…"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgctxt "#30884"
|
|
||||||
msgid "Open Kodi Logfile Uploader…"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
@ -20,16 +20,8 @@ msgid "Catalogue"
|
|||||||
msgstr "Catalogus"
|
msgstr "Catalogus"
|
||||||
|
|
||||||
msgctxt "#30004"
|
msgctxt "#30004"
|
||||||
msgid "Browse the catalogue"
|
msgid "TV Shows and Movies listed by category"
|
||||||
msgstr "Doorblader de catalogus"
|
msgstr "Programma's en films per categorie"
|
||||||
|
|
||||||
msgctxt "#30005"
|
|
||||||
msgid "Recommendations"
|
|
||||||
msgstr "Aanbevelingen"
|
|
||||||
|
|
||||||
msgctxt "#30006"
|
|
||||||
msgid "Show the recommendations"
|
|
||||||
msgstr "Doorblader de aanbevelingen"
|
|
||||||
|
|
||||||
msgctxt "#30007"
|
msgctxt "#30007"
|
||||||
msgid "Channels"
|
msgid "Channels"
|
||||||
@ -47,24 +39,16 @@ msgctxt "#30010"
|
|||||||
msgid "Search trough the catalogue"
|
msgid "Search trough the catalogue"
|
||||||
msgstr "Doorzoek de catalogus"
|
msgstr "Doorzoek de catalogus"
|
||||||
|
|
||||||
msgctxt "#30011"
|
|
||||||
msgid "My List"
|
|
||||||
msgstr "Mijn lijst"
|
|
||||||
|
|
||||||
msgctxt "#30012"
|
|
||||||
msgid "Browse My List"
|
|
||||||
msgstr "Bekijk mijn lijst"
|
|
||||||
|
|
||||||
msgctxt "#30013"
|
msgctxt "#30013"
|
||||||
msgid "TV guide"
|
msgid "TV guide"
|
||||||
msgstr "Tv-gids"
|
msgstr "Tv-gids"
|
||||||
|
|
||||||
|
msgctxt "#30014"
|
||||||
|
msgid "Browse the TV Guide"
|
||||||
|
msgstr "Doorblader de tv-gids"
|
||||||
|
|
||||||
|
|
||||||
### SUBMENUS
|
### SUBMENUS
|
||||||
msgctxt "#30052"
|
|
||||||
msgid "Watch live [B]{channel}[/B]"
|
|
||||||
msgstr "Kijk live [B]{channel}[/B]"
|
|
||||||
|
|
||||||
msgctxt "#30053"
|
msgctxt "#30053"
|
||||||
msgid "TV Guide for [B]{channel}[/B]"
|
msgid "TV Guide for [B]{channel}[/B]"
|
||||||
msgstr "Tv-gids voor [B]{channel}[/B]"
|
msgstr "Tv-gids voor [B]{channel}[/B]"
|
||||||
@ -81,28 +65,16 @@ msgctxt "#30056"
|
|||||||
msgid "Browse the Catalog for [B]{channel}[/B]"
|
msgid "Browse the Catalog for [B]{channel}[/B]"
|
||||||
msgstr "Doorblader de catalogus voor [B]{channel}[/B]"
|
msgstr "Doorblader de catalogus voor [B]{channel}[/B]"
|
||||||
|
|
||||||
msgctxt "#30059"
|
|
||||||
msgid "Clips of [B]{program}[/B]"
|
|
||||||
msgstr "Clips van [B]{program}[/B]"
|
|
||||||
|
|
||||||
msgctxt "#30060"
|
|
||||||
msgid "Watch short clips of [B]{program}[/B]"
|
|
||||||
msgstr "Bekijk korte videoclips van [B]{program}[/B]"
|
|
||||||
|
|
||||||
|
|
||||||
### CONTEXT MENU
|
### CONTEXT MENU
|
||||||
msgctxt "#30100"
|
|
||||||
msgid "Add to My List"
|
|
||||||
msgstr "Toevoegen aan mijn lijst"
|
|
||||||
|
|
||||||
msgctxt "#30101"
|
|
||||||
msgid "Remove from My List"
|
|
||||||
msgstr "Verwijderen uit mijn lijst"
|
|
||||||
|
|
||||||
msgctxt "#30102"
|
msgctxt "#30102"
|
||||||
msgid "Go to Program"
|
msgid "Go to Program"
|
||||||
msgstr "Ga naar programma"
|
msgstr "Ga naar programma"
|
||||||
|
|
||||||
|
msgctxt "#30103"
|
||||||
|
msgid "Download to cache"
|
||||||
|
msgstr "Downloaden naar cache"
|
||||||
|
|
||||||
|
|
||||||
### CODE
|
### CODE
|
||||||
msgctxt "#30204"
|
msgctxt "#30204"
|
||||||
@ -141,10 +113,18 @@ msgctxt "#30702"
|
|||||||
msgid "An error occurred while authenticating: {error}."
|
msgid "An error occurred while authenticating: {error}."
|
||||||
msgstr "Er is een fout opgetreden tijdens het aanmelden: {error}."
|
msgstr "Er is een fout opgetreden tijdens het aanmelden: {error}."
|
||||||
|
|
||||||
|
msgctxt "#30709"
|
||||||
|
msgid "Geo-blocked video"
|
||||||
|
msgstr "Video is geografisch geblokkeerd"
|
||||||
|
|
||||||
msgctxt "#30710"
|
msgctxt "#30710"
|
||||||
msgid "This video is geo-blocked and can't be played from your location."
|
msgid "This video is geo-blocked and can't be played from your location."
|
||||||
msgstr "Deze video is geografisch geblokkeerd en kan niet worden afgespeeld vanaf je locatie."
|
msgstr "Deze video is geografisch geblokkeerd en kan niet worden afgespeeld vanaf je locatie."
|
||||||
|
|
||||||
|
msgctxt "#30711"
|
||||||
|
msgid "Unavailable video"
|
||||||
|
msgstr "Onbeschikbare video"
|
||||||
|
|
||||||
msgctxt "#30712"
|
msgctxt "#30712"
|
||||||
msgid "The video is unavailable and can't be played right now."
|
msgid "The video is unavailable and can't be played right now."
|
||||||
msgstr "Deze video is niet beschikbaar en kan nu niet worden afgespeeld."
|
msgstr "Deze video is niet beschikbaar en kan nu niet worden afgespeeld."
|
||||||
@ -153,21 +133,57 @@ msgctxt "#30713"
|
|||||||
msgid "The requested video was not found in the guide."
|
msgid "The requested video was not found in the guide."
|
||||||
msgstr "De gevraagde video werd niet gevonden in de tv-gids."
|
msgstr "De gevraagde video werd niet gevonden in de tv-gids."
|
||||||
|
|
||||||
|
msgctxt "#30714"
|
||||||
|
msgid "Local metadata is cleared."
|
||||||
|
msgstr "De lokale metadata is verwijderd."
|
||||||
|
|
||||||
|
msgctxt "#30715"
|
||||||
|
msgid "Updating metadata"
|
||||||
|
msgstr "Vernieuwen metadata"
|
||||||
|
|
||||||
|
msgctxt "#30716"
|
||||||
|
msgid "Updating metadata ({index}/{total})..."
|
||||||
|
msgstr "Vernieuwen metadata ({index}/{total})..."
|
||||||
|
|
||||||
msgctxt "#30717"
|
msgctxt "#30717"
|
||||||
msgid "This program is not available in the catalogue."
|
msgid "This program is not available in the catalogue."
|
||||||
msgstr "Dit programma is niet beschikbaar in de catalogus."
|
msgstr "Dit programma is niet beschikbaar in de catalogus."
|
||||||
|
|
||||||
msgctxt "#30718"
|
msgctxt "#30718"
|
||||||
msgid "There is no live stream available for {channel}."
|
msgid "Could not cache this episode since the cache folder is not set or does not exist."
|
||||||
msgstr "Er is geen live stream beschikbaar voor {channel}."
|
msgstr "Kon deze aflevering niet cachen omdat de cache folder niet is ingesteld is of niet bestaat."
|
||||||
|
|
||||||
msgctxt "#30719"
|
msgctxt "#30719"
|
||||||
msgid "This video cannot be played."
|
msgid "Could not cache this episode since ffmpeg seems to be unavailable."
|
||||||
msgstr "Deze video kan niet afgespeeld worden."
|
msgstr "Kon deze aflevering niet cachen omdat ffmpeg niet beschikbaar lijkt te zijn."
|
||||||
|
|
||||||
msgctxt "#30720"
|
msgctxt "#30720"
|
||||||
msgid "This video is not available abroad."
|
msgid "This episode is cached locally. Do you want to play from cache or stream it?"
|
||||||
msgstr "Deze video is niet beschikbaar in het buitenland."
|
msgstr "Deze aflevering is lokaal gecached. Wil je deze afspelen vanuit de cache of streamen?"
|
||||||
|
|
||||||
|
msgctxt "#30721"
|
||||||
|
msgid "Stream"
|
||||||
|
msgstr "Stream"
|
||||||
|
|
||||||
|
msgctxt "#30722"
|
||||||
|
msgid "Play from cache"
|
||||||
|
msgstr "Afspelen vanuit de cache"
|
||||||
|
|
||||||
|
msgctxt "#30723"
|
||||||
|
msgid "Starting download..."
|
||||||
|
msgstr "Bezig met starten van de download..."
|
||||||
|
|
||||||
|
msgctxt "#30724"
|
||||||
|
msgid "Downloading... ({amount}%)"
|
||||||
|
msgstr "Bezig met downloaden... ({amount}%)"
|
||||||
|
|
||||||
|
msgctxt "#30725"
|
||||||
|
msgid "Download has finished. You can now play this episode from cache."
|
||||||
|
msgstr "De download is voltooid. Je kan deze aflevering nu afspelen vanuit de cache."
|
||||||
|
|
||||||
|
msgctxt "#30726"
|
||||||
|
msgid "This episode is already cached. Do you want to download it again?"
|
||||||
|
msgstr "Deze aflevering is al gecached. Wil je deze opnieuw downloaden?"
|
||||||
|
|
||||||
|
|
||||||
### SETTINGS
|
### SETTINGS
|
||||||
@ -179,11 +195,11 @@ msgctxt "#30801"
|
|||||||
msgid "Credentials"
|
msgid "Credentials"
|
||||||
msgstr "Inloggegevens"
|
msgstr "Inloggegevens"
|
||||||
|
|
||||||
msgctxt "#30802"
|
msgctxt "#30803"
|
||||||
msgid "Email address"
|
msgid "Email address"
|
||||||
msgstr "E-mailadres"
|
msgstr "E-mailadres"
|
||||||
|
|
||||||
msgctxt "#30803"
|
msgctxt "#30805"
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Wachtwoord"
|
msgstr "Wachtwoord"
|
||||||
|
|
||||||
@ -191,46 +207,26 @@ msgctxt "#30820"
|
|||||||
msgid "Interface"
|
msgid "Interface"
|
||||||
msgstr "Interface"
|
msgstr "Interface"
|
||||||
|
|
||||||
msgctxt "#30821"
|
msgctxt "#30827"
|
||||||
msgid "Show unavailable programs"
|
msgid "Metadata"
|
||||||
msgstr "Toon onbeschikbare programma's"
|
msgstr "Metadata"
|
||||||
|
|
||||||
|
msgctxt "#30829"
|
||||||
|
msgid "Periodically refresh metadata in the background"
|
||||||
|
msgstr "Vernieuw de lokale metdata automatisch in de achtergrond"
|
||||||
|
|
||||||
|
msgctxt "#30831"
|
||||||
|
msgid "Update local metadata now"
|
||||||
|
msgstr "De lokale metadata nu vernieuwen"
|
||||||
|
|
||||||
msgctxt "#30840"
|
msgctxt "#30840"
|
||||||
msgid "Integration"
|
msgid "Playback from cache"
|
||||||
msgstr "Integratie"
|
msgstr "Afspelen vanuit de cache"
|
||||||
|
|
||||||
msgctxt "#30841"
|
msgctxt "#30841"
|
||||||
msgid "IPTV Manager"
|
msgid "Allow to download episodes to a cache"
|
||||||
msgstr "IPTV Manager"
|
msgstr "Toestaan om afleveringen te downloaden naar de cache"
|
||||||
|
|
||||||
msgctxt "#30842"
|
|
||||||
msgid "Install IPTV Manager add-on…"
|
|
||||||
msgstr "Installeer de IPTV Manager add-on…"
|
|
||||||
|
|
||||||
msgctxt "#30843"
|
msgctxt "#30843"
|
||||||
msgid "Enable IPTV Manager integration"
|
msgid "Select the folder for the cached episodes"
|
||||||
msgstr "Activeer IPTV Manager integratie"
|
msgstr "Selecteer de map voor de gecachte afleveringen"
|
||||||
|
|
||||||
msgctxt "#30844"
|
|
||||||
msgid "IPTV Manager settings…"
|
|
||||||
msgstr "IPTV Manager instellingen…"
|
|
||||||
|
|
||||||
msgctxt "#30880"
|
|
||||||
msgid "Expert"
|
|
||||||
msgstr "Expert"
|
|
||||||
|
|
||||||
msgctxt "#30881"
|
|
||||||
msgid "Logging"
|
|
||||||
msgstr "Logboek"
|
|
||||||
|
|
||||||
msgctxt "#30882"
|
|
||||||
msgid "Enable debug logging"
|
|
||||||
msgstr "Activeer debug logging"
|
|
||||||
|
|
||||||
msgctxt "#30883"
|
|
||||||
msgid "Install Kodi Logfile Uploader…"
|
|
||||||
msgstr "Installeer Kodi Logfile Uploader…"
|
|
||||||
|
|
||||||
msgctxt "#30884"
|
|
||||||
msgid "Open Kodi Logfile Uploader…"
|
|
||||||
msgstr "Open Kodi Logfile Uploader…"
|
|
||||||
|
@ -9,13 +9,9 @@ from routing import Plugin
|
|||||||
|
|
||||||
from resources.lib import kodilogging
|
from resources.lib import kodilogging
|
||||||
|
|
||||||
try: # Python 3
|
kodilogging.config()
|
||||||
from urllib.parse import unquote
|
|
||||||
except ImportError: # Python 2
|
|
||||||
from urllib import unquote
|
|
||||||
|
|
||||||
routing = Plugin() # pylint: disable=invalid-name
|
routing = Plugin() # pylint: disable=invalid-name
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('addon')
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/')
|
@routing.route('/')
|
||||||
@ -39,25 +35,18 @@ def show_channel_menu(channel):
|
|||||||
Channels().show_channel_menu(channel)
|
Channels().show_channel_menu(channel)
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/channels/<channel>/tvguide')
|
@routing.route('/tvguide/channel/<channel>')
|
||||||
def show_channel_tvguide(channel):
|
def show_tvguide_channel(channel):
|
||||||
""" Shows the dates in the tv guide """
|
""" Shows the dates in the tv guide """
|
||||||
from resources.lib.modules.tvguide import TvGuide
|
from resources.lib.modules.tvguide import TvGuide
|
||||||
TvGuide().show_channel(channel)
|
TvGuide().show_tvguide_channel(channel)
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/channels/<channel>/tvguide/<date>')
|
@routing.route('/tvguide/channel/<channel>/<date>')
|
||||||
def show_channel_tvguide_detail(channel=None, date=None):
|
def show_tvguide_detail(channel=None, date=None):
|
||||||
""" Shows the programs of a specific date in the tv guide """
|
""" Shows the programs of a specific date in the tv guide """
|
||||||
from resources.lib.modules.tvguide import TvGuide
|
from resources.lib.modules.tvguide import TvGuide
|
||||||
TvGuide().show_detail(channel, date)
|
TvGuide().show_tvguide_detail(channel, date)
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/channels/<channel>/catalog')
|
|
||||||
def show_channel_catalog(channel):
|
|
||||||
""" Show the catalog of a channel """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().show_catalog_channel(channel)
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/catalog')
|
@routing.route('/catalog')
|
||||||
@ -67,74 +56,25 @@ def show_catalog():
|
|||||||
Catalog().show_catalog()
|
Catalog().show_catalog()
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/catalog/<program>')
|
@routing.route('/catalog/by-channel/<channel>')
|
||||||
def show_catalog_program(program):
|
def show_catalog_channel(channel):
|
||||||
|
""" Show a category in the catalog """
|
||||||
|
from resources.lib.modules.catalog import Catalog
|
||||||
|
Catalog().show_catalog_channel(channel)
|
||||||
|
|
||||||
|
|
||||||
|
@routing.route('/catalog/program/<channel>/<program>')
|
||||||
|
def show_catalog_program(channel, program):
|
||||||
""" Show a program from the catalog """
|
""" Show a program from the catalog """
|
||||||
from resources.lib.modules.catalog import Catalog
|
from resources.lib.modules.catalog import Catalog
|
||||||
Catalog().show_program(program)
|
Catalog().show_program(channel, program)
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/catalog/<program>/clips')
|
@routing.route('/catalog/program/<channel>/<program>/<season>')
|
||||||
def show_catalog_program_clips(program):
|
def show_catalog_program_season(channel, program, season):
|
||||||
""" Show the clips from a program """
|
""" Show a program from the catalog """
|
||||||
from resources.lib.modules.catalog import Catalog
|
from resources.lib.modules.catalog import Catalog
|
||||||
Catalog().show_program_clips(program)
|
Catalog().show_program_season(channel, program, season)
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/catalog/<program>/season/<season>')
|
|
||||||
def show_catalog_program_season(program, season):
|
|
||||||
""" Show a season from a program """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().show_program_season(program, season)
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/category')
|
|
||||||
def show_categories():
|
|
||||||
""" Show the catalog by category """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().show_categories()
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/category/<category>')
|
|
||||||
def show_category(category):
|
|
||||||
""" Show the catalog by category """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().show_category(category)
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/recommendations')
|
|
||||||
def show_recommendations():
|
|
||||||
""" Show my list """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().show_recommendations()
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/recommendations/<category>')
|
|
||||||
def show_recommendations_category(category):
|
|
||||||
""" Show my list """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().show_recommendations_category(category)
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/mylist')
|
|
||||||
def show_mylist():
|
|
||||||
""" Show my list """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().show_mylist()
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/mylist/add/<uuid>')
|
|
||||||
def mylist_add(uuid):
|
|
||||||
""" Add a program to My List """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().mylist_add(uuid)
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/mylist/del/<uuid>')
|
|
||||||
def mylist_del(uuid):
|
|
||||||
""" Remove a program from My List """
|
|
||||||
from resources.lib.modules.catalog import Catalog
|
|
||||||
Catalog().mylist_del(uuid)
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/search')
|
@routing.route('/search')
|
||||||
@ -145,50 +85,39 @@ def show_search(query=None):
|
|||||||
Search().show_search(query)
|
Search().show_search(query)
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/play/live/<channel>')
|
@routing.route('/play/catalog/<uuid>')
|
||||||
def play_live(channel):
|
def play(uuid):
|
||||||
""" Play the requested item """
|
""" Play the requested item """
|
||||||
from resources.lib.modules.player import Player
|
from resources.lib.modules.player import Player
|
||||||
Player().live(channel)
|
Player().play(uuid)
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/play/epg/<channel>/<timestamp>')
|
@routing.route('/download/catalog/<uuid>')
|
||||||
def play_epg(channel, timestamp):
|
def download(uuid):
|
||||||
""" Play the requested item """
|
""" Download the requested item to cache """
|
||||||
from resources.lib.modules.tvguide import TvGuide
|
|
||||||
TvGuide().play_epg_datetime(channel, timestamp)
|
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/play/catalog')
|
|
||||||
@routing.route('/play/catalog/<uuid>/<content_type>')
|
|
||||||
def play_catalog(uuid=None, content_type=None):
|
|
||||||
""" Play the requested item """
|
|
||||||
from resources.lib.modules.player import Player
|
from resources.lib.modules.player import Player
|
||||||
Player().play(uuid, content_type)
|
Player().download(uuid)
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/play/page/<page>')
|
@routing.route('/play/page/<channel>/<page>')
|
||||||
def play_from_page(page):
|
def play_from_page(channel, page):
|
||||||
""" Play the requested item """
|
""" Play the requested item """
|
||||||
|
try: # Python 3
|
||||||
|
from urllib.parse import unquote
|
||||||
|
except ImportError: # Python 2
|
||||||
|
from urllib import unquote
|
||||||
|
|
||||||
from resources.lib.modules.player import Player
|
from resources.lib.modules.player import Player
|
||||||
Player().play_from_page(unquote(page))
|
Player().play_from_page(channel, unquote(page))
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/iptv/channels')
|
@routing.route('/metadata/update')
|
||||||
def iptv_channels():
|
def metadata_update():
|
||||||
""" Generate channel data for the Kodi PVR integration """
|
""" Update the metadata for the listings (called from settings) """
|
||||||
from resources.lib.modules.iptvmanager import IPTVManager
|
from resources.lib.modules.metadata import Metadata
|
||||||
IPTVManager(int(routing.args['port'][0])).send_channels() # pylint: disable=too-many-function-args
|
Metadata().update()
|
||||||
|
|
||||||
|
|
||||||
@routing.route('/iptv/epg')
|
|
||||||
def iptv_epg():
|
|
||||||
""" Generate EPG data for the Kodi PVR integration """
|
|
||||||
from resources.lib.modules.iptvmanager import IPTVManager
|
|
||||||
IPTVManager(int(routing.args['port'][0])).send_epg() # pylint: disable=too-many-function-args
|
|
||||||
|
|
||||||
|
|
||||||
def run(params):
|
def run(params):
|
||||||
""" Run the routing plugin """
|
""" Run the routing plugin """
|
||||||
kodilogging.config()
|
|
||||||
routing.run(params)
|
routing.run(params)
|
||||||
|
84
resources/lib/downloader.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Episode Downloader"""
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger('downloader')
|
||||||
|
|
||||||
|
|
||||||
|
class Downloader:
|
||||||
|
""" Allows to download an episode to disk for caching purposes. """
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check():
|
||||||
|
""" Check if we have ffmpeg installed."""
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(['ffmpeg', '-version'], stderr=subprocess.PIPE)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Wait for the process to finish
|
||||||
|
output = proc.stderr.readlines()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
# Check error code
|
||||||
|
if proc.returncode != 0:
|
||||||
|
_LOGGER.error(output)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# TODO: Check version
|
||||||
|
_LOGGER.debug('Output: %s', output)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def download(stream, output, progress_callback=None):
|
||||||
|
"""Download the stream to destination."""
|
||||||
|
try:
|
||||||
|
cmd = ['ffmpeg', '-y', '-loglevel', 'info', '-i', stream, '-codec', 'copy', output]
|
||||||
|
# `universal_newlines` makes proc.stderr.readline() also work on \r
|
||||||
|
proc = subprocess.Popen(cmd, stderr=subprocess.PIPE, universal_newlines=True)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
regex_total = re.compile(r"Duration: (\d{2}):(\d{2}):(\d{2})")
|
||||||
|
regex_current = re.compile(r"time=(\d{2}):(\d{2}):(\d{2})")
|
||||||
|
|
||||||
|
# Keep looping over ffmpeg output
|
||||||
|
total = None
|
||||||
|
while True:
|
||||||
|
line = proc.stderr.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
_LOGGER.debug('ffmpeg output: %s', line.rstrip())
|
||||||
|
|
||||||
|
# Read the current status that is printed every few seconds.
|
||||||
|
match = regex_current.search(line)
|
||||||
|
if match and progress_callback:
|
||||||
|
cancel = progress_callback(total, int(match.group(1)) * 3600 + int(match.group(2)) * 60 + int(match.group(3)))
|
||||||
|
if cancel:
|
||||||
|
proc.terminate()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Read the total stream duration if we haven't found it already. It's there somewhere in the output. We'll find it.
|
||||||
|
if not total:
|
||||||
|
match = regex_total.search(line)
|
||||||
|
if match:
|
||||||
|
total = int(match.group(1)) * 3600 + int(match.group(2)) * 60 + int(match.group(3))
|
||||||
|
|
||||||
|
# Wait for ffmpeg to be fully finished
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
# Check error code
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
@ -8,23 +8,15 @@ import logging
|
|||||||
import xbmc
|
import xbmc
|
||||||
import xbmcaddon
|
import xbmcaddon
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
|
||||||
|
|
||||||
ADDON = xbmcaddon.Addon()
|
|
||||||
|
|
||||||
|
|
||||||
class KodiLogHandler(logging.StreamHandler):
|
class KodiLogHandler(logging.StreamHandler):
|
||||||
""" A log handler for Kodi """
|
""" A log handler for Kodi """
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logging.StreamHandler.__init__(self)
|
logging.StreamHandler.__init__(self)
|
||||||
formatter = logging.Formatter("[{}] [%(name)s] %(message)s".format(ADDON.getAddonInfo("id")))
|
addon_id = xbmcaddon.Addon().getAddonInfo("id")
|
||||||
|
formatter = logging.Formatter("[{}] [%(name)s] %(message)s".format(addon_id))
|
||||||
self.setFormatter(formatter)
|
self.setFormatter(formatter)
|
||||||
# xbmc.LOGNOTICE is deprecated in Kodi 19 Matrix
|
|
||||||
if kodiutils.kodi_version_major() > 18:
|
|
||||||
self.info_level = xbmc.LOGINFO
|
|
||||||
else:
|
|
||||||
self.info_level = xbmc.LOGNOTICE
|
|
||||||
|
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
""" Emit a log message """
|
""" Emit a log message """
|
||||||
@ -32,16 +24,10 @@ class KodiLogHandler(logging.StreamHandler):
|
|||||||
logging.CRITICAL: xbmc.LOGFATAL,
|
logging.CRITICAL: xbmc.LOGFATAL,
|
||||||
logging.ERROR: xbmc.LOGERROR,
|
logging.ERROR: xbmc.LOGERROR,
|
||||||
logging.WARNING: xbmc.LOGWARNING,
|
logging.WARNING: xbmc.LOGWARNING,
|
||||||
logging.INFO: self.info_level,
|
logging.INFO: xbmc.LOGINFO,
|
||||||
logging.DEBUG: xbmc.LOGDEBUG,
|
logging.DEBUG: xbmc.LOGDEBUG,
|
||||||
logging.NOTSET: xbmc.LOGNONE,
|
logging.NOTSET: xbmc.LOGNONE,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Map DEBUG level to info_level if debug logging setting has been activated
|
|
||||||
# This is for troubleshooting only
|
|
||||||
if ADDON.getSetting('debug_logging') == 'true':
|
|
||||||
levels[logging.DEBUG] = self.info_level
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
xbmc.log(self.format(record), levels[record.levelno])
|
xbmc.log(self.format(record), levels[record.levelno])
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
@ -54,5 +40,5 @@ class KodiLogHandler(logging.StreamHandler):
|
|||||||
def config():
|
def config():
|
||||||
""" Setup the logger with this handler """
|
""" Setup the logger with this handler """
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
logger.setLevel(logging.DEBUG) # Make sure we pass all messages, Kodi will do some filtering itself.
|
|
||||||
logger.addHandler(KodiLogHandler())
|
logger.addHandler(KodiLogHandler())
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
@ -4,63 +4,35 @@
|
|||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcaddon
|
import xbmcaddon
|
||||||
import xbmcgui
|
import xbmcgui
|
||||||
import xbmcplugin
|
import xbmcplugin
|
||||||
import xbmcvfs
|
|
||||||
|
|
||||||
try: # Python 3
|
|
||||||
from html import unescape
|
|
||||||
except ImportError: # Python 2
|
|
||||||
from HTMLParser import HTMLParser
|
|
||||||
|
|
||||||
unescape = HTMLParser().unescape
|
|
||||||
|
|
||||||
ADDON = xbmcaddon.Addon()
|
ADDON = xbmcaddon.Addon()
|
||||||
|
|
||||||
SORT_METHODS = {
|
SORT_METHODS = dict(
|
||||||
'unsorted': xbmcplugin.SORT_METHOD_UNSORTED,
|
unsorted=xbmcplugin.SORT_METHOD_UNSORTED,
|
||||||
'label': xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS,
|
label=xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS,
|
||||||
'title': xbmcplugin.SORT_METHOD_TITLE,
|
title=xbmcplugin.SORT_METHOD_TITLE,
|
||||||
'episode': xbmcplugin.SORT_METHOD_EPISODE,
|
episode=xbmcplugin.SORT_METHOD_EPISODE,
|
||||||
'duration': xbmcplugin.SORT_METHOD_DURATION,
|
duration=xbmcplugin.SORT_METHOD_DURATION,
|
||||||
'year': xbmcplugin.SORT_METHOD_VIDEO_YEAR,
|
year=xbmcplugin.SORT_METHOD_VIDEO_YEAR,
|
||||||
'date': xbmcplugin.SORT_METHOD_DATE
|
date=xbmcplugin.SORT_METHOD_DATE,
|
||||||
}
|
)
|
||||||
DEFAULT_SORT_METHODS = [
|
DEFAULT_SORT_METHODS = [
|
||||||
'unsorted', 'title'
|
'unsorted', 'title'
|
||||||
]
|
]
|
||||||
|
|
||||||
HTML_MAPPING = [
|
_LOGGER = logging.getLogger('kodiutils')
|
||||||
(re.compile(r'<(/?)i(|\s[^>]+)>', re.I), '[\\1I]'),
|
|
||||||
(re.compile(r'<(/?)b(|\s[^>]+)>', re.I), '[\\1B]'),
|
|
||||||
(re.compile(r'<em(|\s[^>]+)>', re.I), '[I]'),
|
|
||||||
(re.compile(r'</em>', re.I), '[/I]'),
|
|
||||||
(re.compile(r'<(strong|h\d)>', re.I), '[B]'),
|
|
||||||
(re.compile(r'</(strong|h\d)>', re.I), '[/B]'),
|
|
||||||
(re.compile(r'<li>', re.I), '- '),
|
|
||||||
(re.compile(r'</?(li|ul|ol)(|\s[^>]+)>', re.I), '\n'),
|
|
||||||
(re.compile(r'</?(code|div|p|pre|span)(|\s[^>]+)>', re.I), ''),
|
|
||||||
(re.compile(r'<br />', re.I), '\n'), # Remove newlines
|
|
||||||
(re.compile('( \n){2,}', re.I), '\n'), # Remove repeating non-blocking spaced newlines
|
|
||||||
(re.compile(' +', re.I), ' '), # Remove double spaces
|
|
||||||
]
|
|
||||||
|
|
||||||
STREAM_HLS = 'hls'
|
|
||||||
STREAM_DASH = 'mpd'
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TitleItem:
|
class TitleItem:
|
||||||
""" This helper object holds all information to be used with Kodi xbmc's ListItem object """
|
""" This helper object holds all information to be used with Kodi xbmc's ListItem object """
|
||||||
|
|
||||||
def __init__(self, title, path=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None,
|
def __init__(self, title, path=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None, context_menu=None, subtitles_path=None,
|
||||||
context_menu=None, subtitles_path=None, is_playable=False, visible=True):
|
is_playable=False):
|
||||||
""" The constructor for the TitleItem class
|
""" The constructor for the TitleItem class
|
||||||
:type title: str
|
:type title: str
|
||||||
:type path: str
|
:type path: str
|
||||||
@ -71,7 +43,6 @@ class TitleItem:
|
|||||||
:type context_menu: list[tuple[str, str]]
|
:type context_menu: list[tuple[str, str]]
|
||||||
:type subtitles_path: list[str]
|
:type subtitles_path: list[str]
|
||||||
:type is_playable: bool
|
:type is_playable: bool
|
||||||
:type visible: bool
|
|
||||||
"""
|
"""
|
||||||
self.title = title
|
self.title = title
|
||||||
self.path = path
|
self.path = path
|
||||||
@ -82,7 +53,6 @@ class TitleItem:
|
|||||||
self.context_menu = context_menu
|
self.context_menu = context_menu
|
||||||
self.subtitles_path = subtitles_path
|
self.subtitles_path = subtitles_path
|
||||||
self.is_playable = is_playable
|
self.is_playable = is_playable
|
||||||
self.visible = visible
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "%r" % self.__dict__
|
return "%r" % self.__dict__
|
||||||
@ -111,15 +81,6 @@ def from_unicode(text, encoding='utf-8', errors='strict'):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def html_to_kodi(text):
|
|
||||||
"""Convert HTML content into Kodi formatted text"""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
for key, val in HTML_MAPPING:
|
|
||||||
text = key.sub(val, text)
|
|
||||||
return unescape(text).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def addon_icon():
|
def addon_icon():
|
||||||
"""Cache and return add-on icon"""
|
"""Cache and return add-on icon"""
|
||||||
return get_addon_info('icon')
|
return get_addon_info('icon')
|
||||||
@ -147,9 +108,6 @@ def addon_path():
|
|||||||
|
|
||||||
def addon_profile():
|
def addon_profile():
|
||||||
"""Cache and return add-on profile"""
|
"""Cache and return add-on profile"""
|
||||||
try: # Kodi 19
|
|
||||||
return to_unicode(xbmcvfs.translatePath(ADDON.getAddonInfo('profile')))
|
|
||||||
except AttributeError: # Kodi 18
|
|
||||||
return to_unicode(xbmc.translatePath(ADDON.getAddonInfo('profile')))
|
return to_unicode(xbmc.translatePath(ADDON.getAddonInfo('profile')))
|
||||||
|
|
||||||
|
|
||||||
@ -160,7 +118,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:
|
||||||
@ -193,9 +151,6 @@ def show_listing(title_items, category=None, sort=None, content=None, cache=True
|
|||||||
# Add the listings
|
# Add the listings
|
||||||
listing = []
|
listing = []
|
||||||
for title_item in title_items:
|
for title_item in title_items:
|
||||||
if not title_item.visible:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Three options:
|
# Three options:
|
||||||
# - item is a virtual directory/folder (not playable, path)
|
# - item is a virtual directory/folder (not playable, path)
|
||||||
# - item is a playable file (playable, path)
|
# - item is a playable file (playable, path)
|
||||||
@ -233,7 +188,7 @@ def show_listing(title_items, category=None, sort=None, content=None, cache=True
|
|||||||
xbmcplugin.endOfDirectory(routing.handle, succeeded, cacheToDisc=cache)
|
xbmcplugin.endOfDirectory(routing.handle, succeeded, cacheToDisc=cache)
|
||||||
|
|
||||||
|
|
||||||
def play(stream, stream_type=STREAM_HLS, license_key=None, title=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None):
|
def play(stream, title=None, art_dict=None, info_dict=None, prop_dict=None):
|
||||||
"""Play the given stream"""
|
"""Play the given stream"""
|
||||||
from resources.lib.addon import routing
|
from resources.lib.addon import routing
|
||||||
|
|
||||||
@ -244,41 +199,12 @@ def play(stream, stream_type=STREAM_HLS, license_key=None, title=None, art_dict=
|
|||||||
play_item.setInfo(type='video', infoLabels=info_dict)
|
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()
|
||||||
@ -289,39 +215,40 @@ def get_search_string(heading='', message=''):
|
|||||||
|
|
||||||
def ok_dialog(heading='', message=''):
|
def ok_dialog(heading='', message=''):
|
||||||
"""Show Kodi's OK dialog"""
|
"""Show Kodi's OK dialog"""
|
||||||
|
from xbmcgui import Dialog
|
||||||
if not heading:
|
if not heading:
|
||||||
heading = addon_name()
|
heading = addon_name()
|
||||||
if kodi_version_major() < 19:
|
if kodi_version_major() < 19:
|
||||||
# pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
return Dialog().ok(heading=heading, line1=message)
|
||||||
return xbmcgui.Dialog().ok(heading=heading, line1=message)
|
return Dialog().ok(heading=heading, message=message)
|
||||||
return xbmcgui.Dialog().ok(heading=heading, message=message)
|
|
||||||
|
|
||||||
|
|
||||||
def yesno_dialog(heading='', message='', nolabel=None, yeslabel=None, autoclose=0):
|
def yesno_dialog(heading='', message='', nolabel=None, yeslabel=None, autoclose=0):
|
||||||
"""Show Kodi's Yes/No dialog"""
|
"""Show Kodi's Yes/No dialog"""
|
||||||
|
from xbmcgui import Dialog
|
||||||
if not heading:
|
if not heading:
|
||||||
heading = addon_name()
|
heading = addon_name()
|
||||||
if kodi_version_major() < 19:
|
if kodi_version_major() < 19:
|
||||||
# pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
return Dialog().yesno(heading=heading, line1=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
|
||||||
return xbmcgui.Dialog().yesno(heading=heading, line1=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
|
return Dialog().yesno(heading=heading, message=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
|
||||||
return xbmcgui.Dialog().yesno(heading=heading, message=message, nolabel=nolabel, yeslabel=yeslabel, autoclose=autoclose)
|
|
||||||
|
|
||||||
|
|
||||||
def notification(heading='', message='', icon='info', time=4000):
|
def notification(heading='', message='', icon='info', time=4000):
|
||||||
"""Show a Kodi notification"""
|
"""Show a Kodi notification"""
|
||||||
|
from xbmcgui import Dialog
|
||||||
if not heading:
|
if not heading:
|
||||||
heading = addon_name()
|
heading = addon_name()
|
||||||
if not icon:
|
if not icon:
|
||||||
icon = addon_icon()
|
icon = addon_icon()
|
||||||
xbmcgui.Dialog().notification(heading=heading, message=message, icon=icon, time=time)
|
Dialog().notification(heading=heading, message=message, icon=icon, time=time)
|
||||||
|
|
||||||
|
|
||||||
def multiselect(heading='', options=None, autoclose=0, preselect=None, use_details=False):
|
def multiselect(heading='', options=None, autoclose=0, preselect=None, use_details=False):
|
||||||
"""Show a Kodi multi-select dialog"""
|
"""Show a Kodi multi-select dialog"""
|
||||||
|
from xbmcgui import Dialog
|
||||||
if not heading:
|
if not heading:
|
||||||
heading = addon_name()
|
heading = addon_name()
|
||||||
return xbmcgui.Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect,
|
return Dialog().multiselect(heading=heading, options=options, autoclose=autoclose, preselect=preselect, useDetails=use_details)
|
||||||
useDetails=use_details)
|
|
||||||
|
|
||||||
|
|
||||||
class progress(xbmcgui.DialogProgress, object): # pylint: disable=invalid-name,useless-object-inheritance
|
class progress(xbmcgui.DialogProgress, object): # pylint: disable=invalid-name,useless-object-inheritance
|
||||||
@ -337,17 +264,13 @@ class progress(xbmcgui.DialogProgress, object): # pylint: disable=invalid-name,
|
|||||||
def create(self, heading, message=''): # pylint: disable=arguments-differ
|
def create(self, heading, message=''): # pylint: disable=arguments-differ
|
||||||
"""Create and show a progress dialog"""
|
"""Create and show a progress dialog"""
|
||||||
if kodi_version_major() < 19:
|
if kodi_version_major() < 19:
|
||||||
lines = message.split('\n', 2)
|
return super(progress, self).create(heading, line1=message)
|
||||||
line1, line2, line3 = (lines + [None] * (3 - len(lines)))
|
|
||||||
return super(progress, self).create(heading, line1=line1, line2=line2, line3=line3) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
|
||||||
return super(progress, self).create(heading, message=message)
|
return super(progress, self).create(heading, message=message)
|
||||||
|
|
||||||
def update(self, percent, message=''): # pylint: disable=arguments-differ
|
def update(self, percent, message=''): # pylint: disable=arguments-differ
|
||||||
"""Update the progress dialog"""
|
"""Update the progress dialog"""
|
||||||
if kodi_version_major() < 19:
|
if kodi_version_major() < 19:
|
||||||
lines = message.split('\n', 2)
|
return super(progress, self).update(percent, line1=message)
|
||||||
line1, line2, line3 = (lines + [None] * (3 - len(lines)))
|
|
||||||
return super(progress, self).update(percent, line1=line1, line2=line2, line3=line3) # pylint: disable=unexpected-keyword-arg,no-value-for-parameter
|
|
||||||
return super(progress, self).update(percent, message=message)
|
return super(progress, self).update(percent, message=message)
|
||||||
|
|
||||||
|
|
||||||
@ -355,7 +278,7 @@ def set_locale():
|
|||||||
"""Load the proper locale for date strings, only once"""
|
"""Load the proper locale for date strings, only once"""
|
||||||
if hasattr(set_locale, 'cached'):
|
if hasattr(set_locale, 'cached'):
|
||||||
return getattr(set_locale, 'cached')
|
return getattr(set_locale, 'cached')
|
||||||
from locale import LC_ALL, Error, setlocale
|
from locale import Error, LC_ALL, setlocale
|
||||||
locale_lang = get_global_setting('locale.language').split('.')[-1]
|
locale_lang = get_global_setting('locale.language').split('.')[-1]
|
||||||
locale_lang = locale_lang[:-2] + locale_lang[-2:].upper()
|
locale_lang = locale_lang[:-2] + locale_lang[-2:].upper()
|
||||||
# NOTE: setlocale() only works if the platform supports the Kodi configured locale
|
# NOTE: setlocale() only works if the platform supports the Kodi configured locale
|
||||||
@ -470,74 +393,13 @@ def open_settings():
|
|||||||
|
|
||||||
def get_global_setting(key):
|
def get_global_setting(key):
|
||||||
"""Get a Kodi setting"""
|
"""Get a Kodi setting"""
|
||||||
result = jsonrpc(method='Settings.GetSettingValue', params={'setting': key})
|
result = jsonrpc(method='Settings.GetSettingValue', params=dict(setting=key))
|
||||||
return result.get('result', {}).get('value')
|
return result.get('result', {}).get('value')
|
||||||
|
|
||||||
|
|
||||||
def set_global_setting(key, value):
|
def set_global_setting(key, value):
|
||||||
"""Set a Kodi setting"""
|
"""Set a Kodi setting"""
|
||||||
return jsonrpc(method='Settings.SetSettingValue', params={'setting': key, 'value': value})
|
return jsonrpc(method='Settings.SetSettingValue', params=dict(setting=key, value=value))
|
||||||
|
|
||||||
|
|
||||||
def has_socks():
|
|
||||||
"""Test if socks is installed, and use a static variable to remember"""
|
|
||||||
if hasattr(has_socks, 'cached'):
|
|
||||||
return getattr(has_socks, 'cached')
|
|
||||||
try:
|
|
||||||
import socks # noqa: F401; pylint: disable=unused-variable,unused-import
|
|
||||||
except ImportError:
|
|
||||||
has_socks.cached = False
|
|
||||||
return None # Detect if this is the first run
|
|
||||||
has_socks.cached = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def get_proxies():
|
|
||||||
"""Return a usable proxies dictionary from Kodi proxy settings"""
|
|
||||||
# Use proxy settings from environment variables
|
|
||||||
env_http_proxy = os.environ.get('HTTP_PROXY')
|
|
||||||
env_https_proxy = os.environ.get('HTTPS_PROXY')
|
|
||||||
if env_http_proxy:
|
|
||||||
return {'http': env_http_proxy, 'https': env_https_proxy or env_http_proxy}
|
|
||||||
|
|
||||||
usehttpproxy = get_global_setting('network.usehttpproxy')
|
|
||||||
if usehttpproxy is not True:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
httpproxytype = int(get_global_setting('network.httpproxytype'))
|
|
||||||
except ValueError:
|
|
||||||
httpproxytype = 0
|
|
||||||
|
|
||||||
socks_supported = has_socks()
|
|
||||||
if httpproxytype != 0 and not socks_supported:
|
|
||||||
# Only open the dialog the first time (to avoid multiple popups)
|
|
||||||
if socks_supported is None:
|
|
||||||
ok_dialog('', localize(30966)) # Requires PySocks
|
|
||||||
return None
|
|
||||||
|
|
||||||
proxy_types = ['http', 'socks4', 'socks4a', 'socks5', 'socks5h']
|
|
||||||
|
|
||||||
proxy = {
|
|
||||||
'scheme': proxy_types[httpproxytype] if 0 <= httpproxytype < 5 else 'http',
|
|
||||||
'server': get_global_setting('network.httpproxyserver'),
|
|
||||||
'port': get_global_setting('network.httpproxyport'),
|
|
||||||
'username': get_global_setting('network.httpproxyusername'),
|
|
||||||
'password': get_global_setting('network.httpproxypassword')
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxy.get('username') and proxy.get('password') and proxy.get('server') and proxy.get('port'):
|
|
||||||
proxy_address = '{scheme}://{username}:{password}@{server}:{port}'.format(**proxy)
|
|
||||||
elif proxy.get('username') and proxy.get('server') and proxy.get('port'):
|
|
||||||
proxy_address = '{scheme}://{username}@{server}:{port}'.format(**proxy)
|
|
||||||
elif proxy.get('server') and proxy.get('port'):
|
|
||||||
proxy_address = '{scheme}://{server}:{port}'.format(**proxy)
|
|
||||||
elif proxy.get('server'):
|
|
||||||
proxy_address = '{scheme}://{server}'.format(**proxy)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {'http': proxy_address, 'https': proxy_address}
|
|
||||||
|
|
||||||
|
|
||||||
def get_cond_visibility(condition):
|
def get_cond_visibility(condition):
|
||||||
@ -563,14 +425,14 @@ def kodi_version_major():
|
|||||||
def get_tokens_path():
|
def get_tokens_path():
|
||||||
"""Cache and return the userdata tokens path"""
|
"""Cache and return the userdata tokens path"""
|
||||||
if not hasattr(get_tokens_path, 'cached'):
|
if not hasattr(get_tokens_path, 'cached'):
|
||||||
get_tokens_path.cached = os.path.join(addon_profile(), 'tokens')
|
get_tokens_path.cached = addon_profile() + 'tokens/'
|
||||||
return getattr(get_tokens_path, 'cached')
|
return getattr(get_tokens_path, 'cached')
|
||||||
|
|
||||||
|
|
||||||
def get_cache_path():
|
def get_cache_path():
|
||||||
"""Cache and return the userdata cache path"""
|
"""Cache and return the userdata cache path"""
|
||||||
if not hasattr(get_cache_path, 'cached'):
|
if not hasattr(get_cache_path, 'cached'):
|
||||||
get_cache_path.cached = os.path.join(addon_profile(), 'cache')
|
get_cache_path.cached = addon_profile() + 'cache/'
|
||||||
return getattr(get_cache_path, 'cached')
|
return getattr(get_cache_path, 'cached')
|
||||||
|
|
||||||
|
|
||||||
@ -629,13 +491,3 @@ def jsonrpc(*args, **kwargs):
|
|||||||
if kwargs.get('jsonrpc') is None:
|
if kwargs.get('jsonrpc') is None:
|
||||||
kwargs.update(jsonrpc='2.0')
|
kwargs.update(jsonrpc='2.0')
|
||||||
return loads(xbmc.executeJSONRPC(dumps(kwargs)))
|
return loads(xbmc.executeJSONRPC(dumps(kwargs)))
|
||||||
|
|
||||||
|
|
||||||
def listdir(path):
|
|
||||||
"""Return all files in a directory (using xbmcvfs)"""
|
|
||||||
return xbmcvfs.listdir(path)
|
|
||||||
|
|
||||||
|
|
||||||
def delete(path):
|
|
||||||
"""Remove a file (using xbmcvfs)"""
|
|
||||||
return xbmcvfs.delete(path)
|
|
||||||
|
@ -8,10 +8,11 @@ import logging
|
|||||||
from resources.lib import kodiutils
|
from resources.lib import kodiutils
|
||||||
from resources.lib.kodiutils import TitleItem
|
from resources.lib.kodiutils import TitleItem
|
||||||
from resources.lib.modules.menu import Menu
|
from resources.lib.modules.menu import Menu
|
||||||
|
from resources.lib.viervijfzes import CHANNELS
|
||||||
from resources.lib.viervijfzes.auth import AuthApi
|
from resources.lib.viervijfzes.auth import AuthApi
|
||||||
from resources.lib.viervijfzes.content import CACHE_PREVENT, ContentApi, UnavailableException
|
from resources.lib.viervijfzes.content import ContentApi, UnavailableException, CACHE_PREVENT
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('catalog')
|
||||||
|
|
||||||
|
|
||||||
class Catalog:
|
class Catalog:
|
||||||
@ -19,18 +20,21 @@ class Catalog:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
""" Initialise object """
|
""" Initialise object """
|
||||||
self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||||
self._api = ContentApi(self._auth, cache_path=kodiutils.get_cache_path())
|
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
||||||
|
self._menu = Menu()
|
||||||
|
|
||||||
def show_catalog(self):
|
def show_catalog(self):
|
||||||
""" Show all the programs of all channels """
|
""" Show all the programs of all channels """
|
||||||
try:
|
try:
|
||||||
items = self._api.get_programs()
|
items = []
|
||||||
|
for channel in list(CHANNELS):
|
||||||
|
items.extend(self._api.get_programs(channel))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
kodiutils.notification(message=str(ex))
|
kodiutils.notification(message=str(ex))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
listing = [Menu.generate_titleitem(item) for item in items]
|
listing = [self._menu.generate_titleitem(item) for item in items]
|
||||||
|
|
||||||
# Sort items by title
|
# Sort items by title
|
||||||
# Used for A-Z listing or when movies and episodes are mixed.
|
# Used for A-Z listing or when movies and episodes are mixed.
|
||||||
@ -48,51 +52,53 @@ class Catalog:
|
|||||||
|
|
||||||
listing = []
|
listing = []
|
||||||
for item in items:
|
for item in items:
|
||||||
listing.append(Menu.generate_titleitem(item))
|
listing.append(self._menu.generate_titleitem(item))
|
||||||
|
|
||||||
# Sort items by title
|
# Sort items by title
|
||||||
# Used for A-Z listing or when movies and episodes are mixed.
|
# Used for A-Z listing or when movies and episodes are mixed.
|
||||||
kodiutils.show_listing(listing, 30003, content='tvshows', sort='title')
|
kodiutils.show_listing(listing, 30003, content='tvshows', sort='title')
|
||||||
|
|
||||||
def show_program(self, program_id):
|
def show_program(self, channel, program_id):
|
||||||
""" Show a program from the catalog
|
""" Show a program from the catalog
|
||||||
|
:type channel: str
|
||||||
:type program_id: str
|
:type program_id: str
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
program = self._api.get_program(program_id, extract_clips=True, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data
|
program = self._api.get_program(channel, program_id, cache=CACHE_PREVENT) # Use CACHE_PREVENT since we want fresh data
|
||||||
except UnavailableException:
|
except UnavailableException:
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
||||||
kodiutils.end_of_directory()
|
kodiutils.end_of_directory()
|
||||||
return
|
return
|
||||||
|
|
||||||
if not program.episodes and not program.clips:
|
if not program.episodes:
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
||||||
kodiutils.end_of_directory()
|
kodiutils.end_of_directory()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Go directly to the season when we have only one season and no clips
|
# Go directly to the season when we have only one season
|
||||||
if not program.clips and len(program.seasons) == 1:
|
if len(program.seasons) == 1:
|
||||||
self.show_program_season(program_id, list(program.seasons.values())[0].uuid)
|
self.show_program_season(channel, program_id, list(program.seasons.values())[0].uuid)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
studio = CHANNELS.get(program.channel, {}).get('studio_icon')
|
||||||
|
|
||||||
listing = []
|
listing = []
|
||||||
|
|
||||||
# Add an '* All seasons' entry when configured in Kodi
|
# Add an '* All seasons' entry when configured in Kodi
|
||||||
if program.seasons and kodiutils.get_global_setting('videolibrary.showallitems') is True:
|
if kodiutils.get_global_setting('videolibrary.showallitems') is True:
|
||||||
listing.append(
|
listing.append(
|
||||||
TitleItem(
|
TitleItem(
|
||||||
title='* %s' % kodiutils.localize(30204), # * All seasons
|
title='* %s' % kodiutils.localize(30204), # * All seasons
|
||||||
path=kodiutils.url_for('show_catalog_program_season', program=program_id, season='-1'),
|
path=kodiutils.url_for('show_catalog_program_season', channel=channel, program=program_id, season='-1'),
|
||||||
art_dict={
|
art_dict={
|
||||||
'fanart': program.fanart,
|
'fanart': program.background,
|
||||||
'poster': program.poster,
|
|
||||||
'landscape': program.thumb,
|
|
||||||
},
|
},
|
||||||
info_dict={
|
info_dict={
|
||||||
'tvshowtitle': program.title,
|
'tvshowtitle': program.title,
|
||||||
'title': kodiutils.localize(30204), # All seasons
|
'title': kodiutils.localize(30204), # All seasons
|
||||||
'plot': program.description,
|
'plot': program.description,
|
||||||
'set': program.title,
|
'set': program.title,
|
||||||
|
'studio': studio,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -102,37 +108,16 @@ class Catalog:
|
|||||||
listing.append(
|
listing.append(
|
||||||
TitleItem(
|
TitleItem(
|
||||||
title=season.title, # kodiutils.localize(30205, season=season.number), # Season {season}
|
title=season.title, # kodiutils.localize(30205, season=season.number), # Season {season}
|
||||||
path=kodiutils.url_for('show_catalog_program_season', program=program_id, season=season.uuid),
|
path=kodiutils.url_for('show_catalog_program_season', channel=channel, program=program_id, season=season.uuid),
|
||||||
art_dict={
|
art_dict={
|
||||||
'fanart': program.fanart,
|
'fanart': program.background,
|
||||||
'poster': program.poster,
|
|
||||||
'landscape': program.thumb,
|
|
||||||
},
|
},
|
||||||
info_dict={
|
info_dict={
|
||||||
'tvshowtitle': program.title,
|
'tvshowtitle': program.title,
|
||||||
'title': kodiutils.localize(30205, season=season.number) if season.number else season.title, # Season {season}
|
'title': kodiutils.localize(30205, season=season.number), # Season {season}
|
||||||
'plot': season.description or program.description,
|
'plot': season.description,
|
||||||
'set': program.title,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add Clips
|
|
||||||
if program.clips:
|
|
||||||
listing.append(
|
|
||||||
TitleItem(
|
|
||||||
title=kodiutils.localize(30059, program=program.title), # Clips for {program}
|
|
||||||
path=kodiutils.url_for('show_catalog_program_clips', program=program_id),
|
|
||||||
art_dict={
|
|
||||||
'fanart': program.fanart,
|
|
||||||
'poster': program.poster,
|
|
||||||
'landscape': program.thumb,
|
|
||||||
},
|
|
||||||
info_dict={
|
|
||||||
'tvshowtitle': program.title,
|
|
||||||
'title': kodiutils.localize(30059, program=program.title), # Clips for {program}
|
|
||||||
'plot': kodiutils.localize(30060, program=program.title), # Watch short clips of {program}
|
|
||||||
'set': program.title,
|
'set': program.title,
|
||||||
|
'studio': studio,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -140,13 +125,14 @@ class Catalog:
|
|||||||
# Sort by label. Some programs return seasons unordered.
|
# Sort by label. Some programs return seasons unordered.
|
||||||
kodiutils.show_listing(listing, 30003, content='tvshows')
|
kodiutils.show_listing(listing, 30003, content='tvshows')
|
||||||
|
|
||||||
def show_program_season(self, program_id, season_uuid):
|
def show_program_season(self, channel, program_id, season_uuid):
|
||||||
""" Show the episodes of a program from the catalog
|
""" Show the episodes of a program from the catalog
|
||||||
|
:type channel: str
|
||||||
:type program_id: str
|
:type program_id: str
|
||||||
:type season_uuid: str
|
:type season_uuid: str
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
program = self._api.get_program(program_id)
|
program = self._api.get_program(channel, program_id)
|
||||||
except UnavailableException:
|
except UnavailableException:
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
||||||
kodiutils.end_of_directory()
|
kodiutils.end_of_directory()
|
||||||
@ -159,122 +145,7 @@ class Catalog:
|
|||||||
# Show the episodes of the season that was selected
|
# Show the episodes of the season that was selected
|
||||||
episodes = [e for e in program.episodes if e.season_uuid == season_uuid]
|
episodes = [e for e in program.episodes if e.season_uuid == season_uuid]
|
||||||
|
|
||||||
listing = [Menu.generate_titleitem(episode) for episode in episodes]
|
listing = [self._menu.generate_titleitem(episode) for episode in episodes]
|
||||||
|
|
||||||
# Sort by episode number by default. Takes seasons into account.
|
# Sort by episode number by default. Takes seasons into account.
|
||||||
kodiutils.show_listing(listing, 30003, content='episodes', sort=['episode', 'duration'])
|
kodiutils.show_listing(listing, 30003, content='episodes', sort=['episode', 'duration'])
|
||||||
|
|
||||||
def show_program_clips(self, program_id):
|
|
||||||
""" Show the clips of a program from the catalog
|
|
||||||
:type program_id: str
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# We need to query the backend, since we don't cache clips.
|
|
||||||
program = self._api.get_program(program_id, extract_clips=True, cache=CACHE_PREVENT)
|
|
||||||
except UnavailableException:
|
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30717)) # This program is not available in the catalogue.
|
|
||||||
kodiutils.end_of_directory()
|
|
||||||
return
|
|
||||||
|
|
||||||
listing = [Menu.generate_titleitem(episode) for episode in program.clips]
|
|
||||||
|
|
||||||
# Sort like we get our results back.
|
|
||||||
kodiutils.show_listing(listing, 30003, content='episodes')
|
|
||||||
|
|
||||||
def show_categories(self):
|
|
||||||
""" Shows the categories """
|
|
||||||
categories = self._api.get_categories()
|
|
||||||
|
|
||||||
listing = []
|
|
||||||
for category in categories:
|
|
||||||
listing.append(TitleItem(title=category.title,
|
|
||||||
path=kodiutils.url_for('show_category', category=category.uuid),
|
|
||||||
info_dict={
|
|
||||||
'title': category.title,
|
|
||||||
}))
|
|
||||||
|
|
||||||
kodiutils.show_listing(listing, 30003, sort=['title'])
|
|
||||||
|
|
||||||
def show_category(self, uuid):
|
|
||||||
""" Shows a category """
|
|
||||||
programs = self._api.get_category_content(int(uuid))
|
|
||||||
|
|
||||||
listing = [
|
|
||||||
Menu.generate_titleitem(program) for program in programs
|
|
||||||
]
|
|
||||||
|
|
||||||
kodiutils.show_listing(listing, 30003, content='tvshows')
|
|
||||||
|
|
||||||
def show_recommendations(self):
|
|
||||||
""" Shows the recommendations """
|
|
||||||
# "Meest bekeken" has a specific API endpoint, the other categories are scraped from the website.
|
|
||||||
listing = [
|
|
||||||
TitleItem(title='Meest bekeken',
|
|
||||||
path=kodiutils.url_for('show_recommendations_category', category='meest-bekeken'),
|
|
||||||
info_dict={
|
|
||||||
'title': 'Meest bekeken',
|
|
||||||
})
|
|
||||||
]
|
|
||||||
|
|
||||||
recommendations = self._api.get_recommendation_categories()
|
|
||||||
for category in recommendations:
|
|
||||||
listing.append(TitleItem(title=category.title,
|
|
||||||
path=kodiutils.url_for('show_recommendations_category', category=category.uuid),
|
|
||||||
info_dict={
|
|
||||||
'title': category.title,
|
|
||||||
}))
|
|
||||||
|
|
||||||
kodiutils.show_listing(listing, 30005, content='tvshows')
|
|
||||||
|
|
||||||
def show_recommendations_category(self, uuid):
|
|
||||||
""" Shows the a category of the recommendations """
|
|
||||||
if uuid == 'meest-bekeken':
|
|
||||||
programs = self._api.get_popular_programs()
|
|
||||||
episodes = []
|
|
||||||
else:
|
|
||||||
recommendations = self._api.get_recommendation_categories()
|
|
||||||
category = next(category for category in recommendations if category.uuid == uuid)
|
|
||||||
programs = category.programs
|
|
||||||
episodes = category.episodes
|
|
||||||
|
|
||||||
listing = []
|
|
||||||
for episode in episodes:
|
|
||||||
title_item = Menu.generate_titleitem(episode)
|
|
||||||
if episode.program_title:
|
|
||||||
title_item.info_dict['title'] = episode.program_title + ' - ' + title_item.title
|
|
||||||
listing.append(title_item)
|
|
||||||
|
|
||||||
for program in programs:
|
|
||||||
listing.append(Menu.generate_titleitem(program))
|
|
||||||
|
|
||||||
kodiutils.show_listing(listing, 30005, content='tvshows')
|
|
||||||
|
|
||||||
def show_mylist(self):
|
|
||||||
""" Show the programs of My List """
|
|
||||||
mylist = self._api.get_mylist()
|
|
||||||
|
|
||||||
listing = [Menu.generate_titleitem(item) for item in mylist]
|
|
||||||
|
|
||||||
# Sort items by title
|
|
||||||
# Used for A-Z listing or when movies and episodes are mixed.
|
|
||||||
kodiutils.show_listing(listing, 30011, content='tvshows', sort='title')
|
|
||||||
|
|
||||||
def mylist_add(self, uuid):
|
|
||||||
""" Add a program to My List """
|
|
||||||
if not uuid:
|
|
||||||
kodiutils.end_of_directory()
|
|
||||||
return
|
|
||||||
|
|
||||||
self._api.mylist_add(uuid)
|
|
||||||
|
|
||||||
kodiutils.end_of_directory()
|
|
||||||
|
|
||||||
def mylist_del(self, uuid):
|
|
||||||
""" Remove a program from My List """
|
|
||||||
if not uuid:
|
|
||||||
kodiutils.end_of_directory()
|
|
||||||
return
|
|
||||||
|
|
||||||
self._api.mylist_del(uuid)
|
|
||||||
|
|
||||||
kodiutils.end_of_directory()
|
|
||||||
|
@ -8,10 +8,8 @@ import logging
|
|||||||
from resources.lib import kodiutils
|
from resources.lib import kodiutils
|
||||||
from resources.lib.kodiutils import TitleItem
|
from resources.lib.kodiutils import TitleItem
|
||||||
from resources.lib.viervijfzes import CHANNELS, STREAM_DICT
|
from resources.lib.viervijfzes import CHANNELS, STREAM_DICT
|
||||||
from resources.lib.viervijfzes.auth import AuthApi
|
|
||||||
from resources.lib.viervijfzes.content import ContentApi
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('channels')
|
||||||
|
|
||||||
|
|
||||||
class Channels:
|
class Channels:
|
||||||
@ -19,8 +17,6 @@ class Channels:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
""" Initialise object """
|
""" Initialise object """
|
||||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
|
||||||
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def show_channels():
|
def show_channels():
|
||||||
@ -37,7 +33,7 @@ class Channels:
|
|||||||
(
|
(
|
||||||
kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel}
|
kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel}
|
||||||
'Container.Update(%s)' %
|
'Container.Update(%s)' %
|
||||||
kodiutils.url_for('show_channel_tvguide', channel=channel.get('epg'))
|
kodiutils.url_for('show_tvguide_channel', channel=channel.get('epg'))
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -54,6 +50,7 @@ class Channels:
|
|||||||
'plot': None,
|
'plot': None,
|
||||||
'playcount': 0,
|
'playcount': 0,
|
||||||
'mediatype': 'video',
|
'mediatype': 'video',
|
||||||
|
'studio': channel.get('studio_icon'),
|
||||||
},
|
},
|
||||||
stream_dict=STREAM_DICT,
|
stream_dict=STREAM_DICT,
|
||||||
context_menu=context_menu
|
context_menu=context_menu
|
||||||
@ -63,67 +60,43 @@ class Channels:
|
|||||||
kodiutils.show_listing(listing, 30007)
|
kodiutils.show_listing(listing, 30007)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def show_channel_menu(channel):
|
def show_channel_menu(key):
|
||||||
""" Shows a TV channel
|
""" Shows a TV channel
|
||||||
:type channel: str
|
:type key: str
|
||||||
"""
|
"""
|
||||||
channel_info = CHANNELS[channel]
|
channel = CHANNELS[key]
|
||||||
|
|
||||||
# Lookup the high resolution logo based on the channel name
|
# Lookup the high resolution logo based on the channel name
|
||||||
fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel_info.get('background'))
|
fanart = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel.get('background'))
|
||||||
icon = '{path}/resources/logos/{logo}'.format(path=kodiutils.addon_path(), logo=channel_info.get('logo'))
|
|
||||||
|
|
||||||
listing = []
|
listing = [
|
||||||
|
|
||||||
listing.append(
|
|
||||||
TitleItem(
|
TitleItem(
|
||||||
title=kodiutils.localize(30052, channel=channel_info.get('name')), # Watch live {channel}
|
title=kodiutils.localize(30053, channel=channel.get('name')), # TV Guide for {channel}
|
||||||
path=kodiutils.url_for('play_live', channel=channel_info.get('name')) + '?.pvr',
|
path=kodiutils.url_for('show_tvguide_channel', channel=key),
|
||||||
art_dict={
|
|
||||||
'icon': icon,
|
|
||||||
'fanart': fanart,
|
|
||||||
},
|
|
||||||
info_dict={
|
|
||||||
'plot': kodiutils.localize(30052, channel=channel_info.get('name')), # Watch live {channel}
|
|
||||||
'playcount': 0,
|
|
||||||
'mediatype': 'video',
|
|
||||||
},
|
|
||||||
is_playable=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if channel_info.get('epg_id'):
|
|
||||||
listing.append(
|
|
||||||
TitleItem(
|
|
||||||
title=kodiutils.localize(30053, channel=channel_info.get('name')), # TV Guide for {channel}
|
|
||||||
path=kodiutils.url_for('show_channel_tvguide', channel=channel),
|
|
||||||
art_dict={
|
art_dict={
|
||||||
'icon': 'DefaultAddonTvInfo.png',
|
'icon': 'DefaultAddonTvInfo.png',
|
||||||
'fanart': fanart,
|
'fanart': fanart,
|
||||||
},
|
},
|
||||||
info_dict={
|
info_dict={
|
||||||
'plot': kodiutils.localize(30054, channel=channel_info.get('name')), # Browse the TV Guide for {channel}
|
'plot': kodiutils.localize(30054, channel=channel.get('name')), # Browse the TV Guide for {channel}
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
)
|
|
||||||
|
|
||||||
listing.append(
|
|
||||||
TitleItem(
|
TitleItem(
|
||||||
title=kodiutils.localize(30055, channel=channel_info.get('name')), # Catalog for {channel}
|
title=kodiutils.localize(30055, channel=channel.get('name')), # Catalog for {channel}
|
||||||
path=kodiutils.url_for('show_channel_catalog', channel=channel),
|
path=kodiutils.url_for('show_catalog_channel', channel=key),
|
||||||
art_dict={
|
art_dict={
|
||||||
'icon': 'DefaultMovieTitle.png',
|
'icon': 'DefaultMovieTitle.png',
|
||||||
'fanart': fanart,
|
'fanart': fanart,
|
||||||
},
|
},
|
||||||
info_dict={
|
info_dict={
|
||||||
'plot': kodiutils.localize(30056, channel=channel_info.get('name')), # Browse the Catalog for {channel}
|
'plot': kodiutils.localize(30056, channel=channel.get('name')), # Browse the Catalog for {channel}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
]
|
||||||
|
|
||||||
# Add YouTube channels
|
# Add YouTube channels
|
||||||
if kodiutils.get_cond_visibility('System.HasAddon(plugin.video.youtube)') != 0:
|
if kodiutils.get_cond_visibility('System.HasAddon(plugin.video.youtube)') != 0:
|
||||||
for youtube in channel_info.get('youtube', []):
|
for youtube in channel.get('youtube', []):
|
||||||
listing.append(
|
listing.append(
|
||||||
TitleItem(
|
TitleItem(
|
||||||
title=kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube
|
title=kodiutils.localize(30206, label=youtube.get('label')), # Watch {label} on YouTube
|
||||||
|
@ -1,98 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""Implementation of IPTVManager class"""
|
|
||||||
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
|
||||||
from resources.lib.viervijfzes import CHANNELS
|
|
||||||
from resources.lib.viervijfzes.epg import EpgApi
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class IPTVManager:
|
|
||||||
"""Interface to IPTV Manager"""
|
|
||||||
|
|
||||||
def __init__(self, port):
|
|
||||||
"""Initialize IPTV Manager object"""
|
|
||||||
self.port = port
|
|
||||||
|
|
||||||
def via_socket(func): # pylint: disable=no-self-argument
|
|
||||||
"""Send the output of the wrapped function to socket"""
|
|
||||||
|
|
||||||
def send(self):
|
|
||||||
"""Decorator to send over a socket"""
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
sock.connect(('127.0.0.1', self.port))
|
|
||||||
try:
|
|
||||||
sock.sendall(json.dumps(func()).encode()) # pylint: disable=not-callable
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
return send
|
|
||||||
|
|
||||||
@via_socket
|
|
||||||
def send_channels(): # pylint: disable=no-method-argument
|
|
||||||
"""Return JSON-STREAMS formatted information to IPTV Manager"""
|
|
||||||
streams = []
|
|
||||||
for key, channel in CHANNELS.items():
|
|
||||||
if channel.get('iptv_id'):
|
|
||||||
streams.append({
|
|
||||||
'id': channel.get('iptv_id'),
|
|
||||||
'name': channel.get('name'),
|
|
||||||
'logo': 'special://home/addons/{addon}/resources/logos/{logo}'.format(addon=kodiutils.addon_id(),
|
|
||||||
logo=channel.get('logo')),
|
|
||||||
'preset': channel.get('iptv_preset'),
|
|
||||||
'stream': 'plugin://plugin.video.viervijfzes/play/live/{channel}'.format(channel=key),
|
|
||||||
'vod': 'plugin://plugin.video.viervijfzes/play/epg/{channel}/{{date}}'.format(channel=key)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {'version': 1, 'streams': streams}
|
|
||||||
|
|
||||||
@via_socket
|
|
||||||
def send_epg(): # pylint: disable=no-method-argument
|
|
||||||
"""Return JSON-EPG formatted information to IPTV Manager"""
|
|
||||||
epg_api = EpgApi()
|
|
||||||
|
|
||||||
try: # Python 3
|
|
||||||
from urllib.parse import quote
|
|
||||||
except ImportError: # Python 2
|
|
||||||
from urllib import quote
|
|
||||||
|
|
||||||
today = datetime.today()
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
for key, channel in CHANNELS.items():
|
|
||||||
iptv_id = channel.get('iptv_id')
|
|
||||||
|
|
||||||
if channel.get('iptv_id'):
|
|
||||||
results[iptv_id] = []
|
|
||||||
|
|
||||||
for i in range(-3, 7):
|
|
||||||
date = today + timedelta(days=i)
|
|
||||||
epg = epg_api.get_epg(key, date.strftime('%Y-%m-%d'))
|
|
||||||
|
|
||||||
results[iptv_id].extend([
|
|
||||||
{
|
|
||||||
'start': program.start.isoformat(),
|
|
||||||
'stop': (program.start + timedelta(seconds=program.duration)).isoformat(),
|
|
||||||
'title': program.program_title,
|
|
||||||
'subtitle': program.episode_title,
|
|
||||||
'description': program.description,
|
|
||||||
'episode': 'S%sE%s' % (program.season, program.number) if program.season and program.number else None,
|
|
||||||
'genre': program.genre,
|
|
||||||
'genre_id': program.genre_id,
|
|
||||||
'image': program.thumb,
|
|
||||||
'stream': kodiutils.url_for('play_from_page',
|
|
||||||
channel=key,
|
|
||||||
page=quote(program.video_url, safe='')) if program.video_url else None
|
|
||||||
}
|
|
||||||
for program in epg if program.duration
|
|
||||||
])
|
|
||||||
|
|
||||||
return {'version': 1, 'epg': results}
|
|
@ -3,19 +3,10 @@
|
|||||||
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
from resources.lib import kodiutils
|
||||||
from resources.lib.kodiutils import TitleItem
|
from resources.lib.kodiutils import TitleItem
|
||||||
from resources.lib.viervijfzes import STREAM_DICT
|
from resources.lib.viervijfzes import CHANNELS, STREAM_DICT
|
||||||
from resources.lib.viervijfzes.content import Episode, Program
|
from resources.lib.viervijfzes.content import Program, Episode
|
||||||
|
|
||||||
try: # Python 3
|
|
||||||
from urllib.parse import quote
|
|
||||||
except ImportError: # Python 2
|
|
||||||
from urllib import quote
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Menu:
|
class Menu:
|
||||||
@ -31,68 +22,35 @@ class Menu:
|
|||||||
TitleItem(
|
TitleItem(
|
||||||
title=kodiutils.localize(30001), # A-Z
|
title=kodiutils.localize(30001), # A-Z
|
||||||
path=kodiutils.url_for('show_catalog'),
|
path=kodiutils.url_for('show_catalog'),
|
||||||
art_dict={
|
art_dict=dict(
|
||||||
'icon': 'DefaultMovieTitle.png',
|
icon='DefaultMovieTitle.png',
|
||||||
'fanart': kodiutils.get_addon_info('fanart')
|
fanart=kodiutils.get_addon_info('fanart'),
|
||||||
},
|
),
|
||||||
info_dict={
|
info_dict=dict(
|
||||||
'plot': kodiutils.localize(30002)
|
plot=kodiutils.localize(30002),
|
||||||
}
|
)
|
||||||
),
|
),
|
||||||
TitleItem(
|
TitleItem(
|
||||||
title=kodiutils.localize(30007), # TV Channels
|
title=kodiutils.localize(30007), # TV Channels
|
||||||
path=kodiutils.url_for('show_channels'),
|
path=kodiutils.url_for('show_channels'),
|
||||||
art_dict={
|
art_dict=dict(
|
||||||
'icon': 'DefaultAddonPVRClient.png',
|
icon='DefaultAddonPVRClient.png',
|
||||||
'fanart': kodiutils.get_addon_info('fanart')
|
fanart=kodiutils.get_addon_info('fanart'),
|
||||||
},
|
|
||||||
info_dict={
|
|
||||||
'plot': kodiutils.localize(30008)
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
TitleItem(
|
info_dict=dict(
|
||||||
title=kodiutils.localize(30003), # Catalog
|
plot=kodiutils.localize(30008),
|
||||||
path=kodiutils.url_for('show_categories'),
|
)
|
||||||
art_dict={
|
|
||||||
'icon': 'DefaultGenre.png',
|
|
||||||
'fanart': kodiutils.get_addon_info('fanart')
|
|
||||||
},
|
|
||||||
info_dict={
|
|
||||||
'plot': kodiutils.localize(30004)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
TitleItem(
|
|
||||||
title=kodiutils.localize(30005), # Recommendations
|
|
||||||
path=kodiutils.url_for('show_recommendations'),
|
|
||||||
art_dict={
|
|
||||||
'icon': 'DefaultFavourites.png',
|
|
||||||
'fanart': kodiutils.get_addon_info('fanart')
|
|
||||||
},
|
|
||||||
info_dict={
|
|
||||||
'plot': kodiutils.localize(30006)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
TitleItem(
|
|
||||||
title=kodiutils.localize(30011), # My List
|
|
||||||
path=kodiutils.url_for('show_mylist'),
|
|
||||||
art_dict={
|
|
||||||
'icon': 'DefaultPlaylist.png',
|
|
||||||
'fanart': kodiutils.get_addon_info('fanart')
|
|
||||||
},
|
|
||||||
info_dict={
|
|
||||||
'plot': kodiutils.localize(30012)
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
TitleItem(
|
TitleItem(
|
||||||
title=kodiutils.localize(30009), # Search
|
title=kodiutils.localize(30009), # Search
|
||||||
path=kodiutils.url_for('show_search'),
|
path=kodiutils.url_for('show_search'),
|
||||||
art_dict={
|
art_dict=dict(
|
||||||
'icon': 'DefaultAddonsSearch.png',
|
icon='DefaultAddonsSearch.png',
|
||||||
'fanart': kodiutils.get_addon_info('fanart')
|
fanart=kodiutils.get_addon_info('fanart'),
|
||||||
},
|
),
|
||||||
info_dict={
|
info_dict=dict(
|
||||||
'plot': kodiutils.localize(30010)
|
plot=kodiutils.localize(30010),
|
||||||
}
|
)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -104,9 +62,14 @@ class Menu:
|
|||||||
:type item: Union[Program, Episode]
|
:type item: Union[Program, Episode]
|
||||||
:rtype TitleItem
|
:rtype TitleItem
|
||||||
"""
|
"""
|
||||||
|
art_dict = {
|
||||||
|
'thumb': item.cover,
|
||||||
|
'cover': item.cover,
|
||||||
|
}
|
||||||
info_dict = {
|
info_dict = {
|
||||||
'title': item.title,
|
'title': item.title,
|
||||||
'plot': item.description,
|
'plot': item.description,
|
||||||
|
'studio': CHANNELS.get(item.channel, {}).get('studio_icon'),
|
||||||
'aired': item.aired.strftime('%Y-%m-%d') if item.aired else None,
|
'aired': item.aired.strftime('%Y-%m-%d') if item.aired else None,
|
||||||
}
|
}
|
||||||
prop_dict = {}
|
prop_dict = {}
|
||||||
@ -115,53 +78,33 @@ class Menu:
|
|||||||
# Program
|
# Program
|
||||||
#
|
#
|
||||||
if isinstance(item, Program):
|
if isinstance(item, Program):
|
||||||
|
art_dict.update({
|
||||||
|
'fanart': item.background,
|
||||||
|
})
|
||||||
info_dict.update({
|
info_dict.update({
|
||||||
'mediatype': None,
|
'mediatype': None,
|
||||||
'season': len(item.seasons) if item.seasons else None,
|
'season': len(item.seasons) if item.seasons else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
art_dict = {
|
if isinstance(item.episodes, list) and not item.episodes:
|
||||||
'poster': item.poster,
|
# We know that we don't have episodes
|
||||||
'landscape': item.thumb,
|
title = '[COLOR gray]' + item.title + '[/COLOR]'
|
||||||
'thumb': item.thumb,
|
else:
|
||||||
'fanart': item.fanart,
|
# We have episodes, or we don't know it
|
||||||
}
|
|
||||||
|
|
||||||
visible = True
|
|
||||||
title = item.title
|
title = item.title
|
||||||
|
|
||||||
context_menu = []
|
|
||||||
if item.uuid:
|
|
||||||
if item.my_list:
|
|
||||||
context_menu.append((
|
|
||||||
kodiutils.localize(30101), # Remove from My List
|
|
||||||
'Container.Update(%s)' %
|
|
||||||
kodiutils.url_for('mylist_del', uuid=item.uuid)
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
context_menu.append((
|
|
||||||
kodiutils.localize(30100), # Add to My List
|
|
||||||
'Container.Update(%s)' %
|
|
||||||
kodiutils.url_for('mylist_add', uuid=item.uuid)
|
|
||||||
))
|
|
||||||
|
|
||||||
context_menu.append((
|
|
||||||
kodiutils.localize(30102), # Go to Program
|
|
||||||
'Container.Update(%s)' %
|
|
||||||
kodiutils.url_for('show_catalog_program', program=item.path)
|
|
||||||
))
|
|
||||||
|
|
||||||
return TitleItem(title=title,
|
return TitleItem(title=title,
|
||||||
path=kodiutils.url_for('show_catalog_program', program=item.path),
|
path=kodiutils.url_for('show_catalog_program', channel=item.channel, program=item.path),
|
||||||
context_menu=context_menu,
|
|
||||||
art_dict=art_dict,
|
art_dict=art_dict,
|
||||||
info_dict=info_dict,
|
info_dict=info_dict)
|
||||||
visible=visible)
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Episode
|
# Episode
|
||||||
#
|
#
|
||||||
if isinstance(item, Episode):
|
if isinstance(item, Episode):
|
||||||
|
art_dict.update({
|
||||||
|
'fanart': item.cover,
|
||||||
|
})
|
||||||
info_dict.update({
|
info_dict.update({
|
||||||
'mediatype': 'episode',
|
'mediatype': 'episode',
|
||||||
'tvshowtitle': item.program_title,
|
'tvshowtitle': item.program_title,
|
||||||
@ -170,30 +113,27 @@ class Menu:
|
|||||||
'episode': item.number,
|
'episode': item.number,
|
||||||
})
|
})
|
||||||
|
|
||||||
art_dict = {
|
|
||||||
'landscape': item.thumb,
|
|
||||||
'thumb': item.thumb,
|
|
||||||
'fanart': item.thumb,
|
|
||||||
}
|
|
||||||
|
|
||||||
stream_dict = STREAM_DICT.copy()
|
stream_dict = STREAM_DICT.copy()
|
||||||
stream_dict.update({
|
stream_dict.update({
|
||||||
'duration': item.duration,
|
'duration': item.duration,
|
||||||
})
|
})
|
||||||
|
|
||||||
if item.uuid:
|
if kodiutils.get_setting_bool('episode_cache_enabled'):
|
||||||
# We have an UUID and can play this item directly
|
context_menu = [(
|
||||||
path = kodiutils.url_for('play_catalog', uuid=item.uuid, content_type=item.content_type)
|
kodiutils.localize(30103), # Download to cache
|
||||||
|
'Container.Update(%s)' %
|
||||||
|
kodiutils.url_for('download', uuid=item.uuid)
|
||||||
|
)]
|
||||||
else:
|
else:
|
||||||
# We don't have an UUID, and first need to fetch the video information from the page
|
context_menu = []
|
||||||
path = kodiutils.url_for('play_from_page', page=quote(item.path, safe=''))
|
|
||||||
|
|
||||||
return TitleItem(title=info_dict['title'],
|
return TitleItem(title=info_dict['title'],
|
||||||
path=path,
|
path=kodiutils.url_for('play', uuid=item.uuid),
|
||||||
art_dict=art_dict,
|
art_dict=art_dict,
|
||||||
info_dict=info_dict,
|
info_dict=info_dict,
|
||||||
stream_dict=stream_dict,
|
stream_dict=stream_dict,
|
||||||
prop_dict=prop_dict,
|
prop_dict=prop_dict,
|
||||||
is_playable=True)
|
is_playable=True,
|
||||||
|
context_menu=context_menu)
|
||||||
|
|
||||||
raise Exception('Unknown video_type')
|
raise Exception('Unknown video_type')
|
||||||
|
54
resources/lib/modules/metadata.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
""" Metadata module """
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
|
from resources.lib import kodiutils
|
||||||
|
from resources.lib.viervijfzes import CHANNELS
|
||||||
|
from resources.lib.viervijfzes.content import ContentApi, Program, CACHE_PREVENT, CACHE_AUTO
|
||||||
|
|
||||||
|
|
||||||
|
class Metadata:
|
||||||
|
""" Code responsible for the management of the local cached metadata """
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
""" Initialise object """
|
||||||
|
self._api = ContentApi(cache_path=kodiutils.get_cache_path())
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
""" Update the metadata with a foreground progress indicator """
|
||||||
|
# Create progress indicator
|
||||||
|
progress = kodiutils.progress(message=kodiutils.localize(30715)) # Updating metadata
|
||||||
|
|
||||||
|
def update_status(i, total):
|
||||||
|
""" Update the progress indicator """
|
||||||
|
progress.update(int(((i + 1) / total) * 100), kodiutils.localize(30716, index=i + 1, total=total)) # Updating metadata ({index}/{total})
|
||||||
|
return progress.iscanceled()
|
||||||
|
|
||||||
|
self.fetch_metadata(callback=update_status, refresh=True)
|
||||||
|
|
||||||
|
# Close progress indicator
|
||||||
|
progress.close()
|
||||||
|
|
||||||
|
def fetch_metadata(self, callback=None, refresh=False):
|
||||||
|
""" Fetch the metadata for all the items in the catalog
|
||||||
|
:type callback: callable
|
||||||
|
:type refresh: bool
|
||||||
|
"""
|
||||||
|
# Fetch all items from the catalog
|
||||||
|
items = []
|
||||||
|
for channel in list(CHANNELS):
|
||||||
|
items.extend(self._api.get_programs(channel, CACHE_PREVENT))
|
||||||
|
count = len(items)
|
||||||
|
|
||||||
|
# Loop over all of them and download the metadata
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if isinstance(item, Program):
|
||||||
|
self._api.get_program(item.channel, item.path, CACHE_PREVENT if refresh else CACHE_AUTO)
|
||||||
|
|
||||||
|
# Run callback after every item
|
||||||
|
if callback and callback(index, count):
|
||||||
|
# Stop when callback returns False
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
@ -4,15 +4,15 @@
|
|||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
from resources.lib import kodiutils
|
||||||
from resources.lib.modules.menu import Menu
|
from resources.lib.downloader import Downloader
|
||||||
from resources.lib.viervijfzes import CHANNELS, ResolvedStream
|
|
||||||
from resources.lib.viervijfzes.auth import AuthApi
|
from resources.lib.viervijfzes.auth import AuthApi
|
||||||
from resources.lib.viervijfzes.aws.cognito_idp import AuthenticationException, InvalidLoginException
|
from resources.lib.viervijfzes.auth_awsidp import InvalidLoginException, AuthenticationException
|
||||||
from resources.lib.viervijfzes.content import CACHE_PREVENT, ContentApi, GeoblockedException, UnavailableException
|
from resources.lib.viervijfzes.content import ContentApi, UnavailableException, GeoblockedException
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('player')
|
||||||
|
|
||||||
|
|
||||||
class Player:
|
class Player:
|
||||||
@ -21,83 +21,94 @@ class Player:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
""" Initialise object """
|
""" Initialise object """
|
||||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||||
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
self._api = ContentApi(auth)
|
||||||
|
|
||||||
# Workaround for Raspberry Pi 3 and older
|
def play_from_page(self, channel, path):
|
||||||
kodiutils.set_global_setting('videoplayer.useomxplayer', True)
|
|
||||||
|
|
||||||
def live(self, channel):
|
|
||||||
""" Play the live channel.
|
|
||||||
:type channel: string
|
|
||||||
"""
|
|
||||||
# TODO: this doesn't work correctly, playing a live program from the PVR won't play something from the beginning
|
|
||||||
# Lookup current program
|
|
||||||
# broadcast = self._epg.get_broadcast(channel, datetime.datetime.now().isoformat())
|
|
||||||
# if broadcast and broadcast.video_url:
|
|
||||||
# self.play_from_page(broadcast.video_url)
|
|
||||||
# return
|
|
||||||
|
|
||||||
channel_url = CHANNELS.get(channel, {'url': channel}).get('url')
|
|
||||||
|
|
||||||
self.play_from_page(channel_url)
|
|
||||||
|
|
||||||
def play_from_page(self, path):
|
|
||||||
""" Play the requested item.
|
""" Play the requested item.
|
||||||
|
:type channel: string
|
||||||
:type path: string
|
:type path: string
|
||||||
"""
|
"""
|
||||||
if not path:
|
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get episode information
|
# Get episode information
|
||||||
episode = self._api.get_episode(path, cache=CACHE_PREVENT)
|
episode = ContentApi().get_episode(channel, path)
|
||||||
resolved_stream = None
|
|
||||||
|
|
||||||
if episode is None:
|
# Play this now we have the uuid
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30712))
|
self.play(episode.uuid)
|
||||||
return
|
|
||||||
|
|
||||||
if episode.stream:
|
def play(self, uuid):
|
||||||
# We already have a resolved stream. Nice!
|
|
||||||
# We don't need credentials for these streams.
|
|
||||||
resolved_stream = ResolvedStream(
|
|
||||||
uuid=episode.uuid,
|
|
||||||
url=episode.stream,
|
|
||||||
)
|
|
||||||
_LOGGER.debug('Already got a resolved stream: %s', resolved_stream)
|
|
||||||
|
|
||||||
if episode.uuid:
|
|
||||||
# Lookup the stream
|
|
||||||
resolved_stream = self._resolve_stream(episode.uuid, episode.content_type)
|
|
||||||
_LOGGER.debug('Resolved stream: %s', resolved_stream)
|
|
||||||
|
|
||||||
if resolved_stream:
|
|
||||||
titleitem = Menu.generate_titleitem(episode)
|
|
||||||
kodiutils.play(resolved_stream.url,
|
|
||||||
resolved_stream.stream_type,
|
|
||||||
resolved_stream.license_key,
|
|
||||||
info_dict=titleitem.info_dict,
|
|
||||||
art_dict=titleitem.art_dict,
|
|
||||||
prop_dict=titleitem.prop_dict)
|
|
||||||
|
|
||||||
def play(self, uuid, content_type):
|
|
||||||
""" Play the requested item.
|
""" Play the requested item.
|
||||||
:type uuid: string
|
:type uuid: string
|
||||||
:type content_type: string
|
|
||||||
"""
|
"""
|
||||||
if not uuid:
|
if kodiutils.get_setting_bool('episode_cache_enabled'):
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
|
# Check for a cached version
|
||||||
|
cached_file = self._check_cached_episode(uuid)
|
||||||
|
if cached_file:
|
||||||
|
kodiutils.play(cached_file)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Lookup the stream
|
# Workaround for Raspberry Pi 3 and older
|
||||||
resolved_stream = self._resolve_stream(uuid, content_type)
|
omxplayer = kodiutils.get_global_setting('videoplayer.useomxplayer')
|
||||||
kodiutils.play(resolved_stream.url, resolved_stream.stream_type, resolved_stream.license_key)
|
if omxplayer is False:
|
||||||
|
kodiutils.set_global_setting('videoplayer.useomxplayer', True)
|
||||||
|
|
||||||
@staticmethod
|
# Resolve the stream
|
||||||
def _resolve_stream(uuid, content_type):
|
resolved_stream = self._fetch_stream(uuid)
|
||||||
""" Resolve the stream for the requested item
|
if not resolved_stream:
|
||||||
|
kodiutils.end_of_directory()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Play this item
|
||||||
|
kodiutils.play(resolved_stream)
|
||||||
|
|
||||||
|
def download(self, uuid):
|
||||||
|
""" Download the requested item to cache.
|
||||||
|
:type uuid: string
|
||||||
|
"""
|
||||||
|
# We can notify Kodi already that we won't be returning a listing.
|
||||||
|
# This also fixes an odd Kodi bug where a starting a Progress() without closing the directory listing causes Kodi to hang.
|
||||||
|
kodiutils.end_of_directory()
|
||||||
|
|
||||||
|
# Check ffmpeg
|
||||||
|
if not Downloader.check():
|
||||||
|
kodiutils.ok_dialog(message=kodiutils.localize(30719)) # Could not download this episode since ffmpeg seems to be unavailable.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check download folder
|
||||||
|
download_folder = kodiutils.get_setting('episode_cache_folder').rstrip('/')
|
||||||
|
if not os.path.exists(download_folder):
|
||||||
|
kodiutils.ok_dialog(message=kodiutils.localize(30718)) # Could not download this episode since the download folder is not set or does not exist.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if we already have downloaded this file
|
||||||
|
download_path = '%s/%s.mp4' % (download_folder, uuid)
|
||||||
|
if os.path.isfile(download_path):
|
||||||
|
# You have already downloaded this episode. Do you want to download it again?
|
||||||
|
result = kodiutils.yesno_dialog(message=kodiutils.localize(30726))
|
||||||
|
if not result:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Download this item
|
||||||
|
downloader = Downloader()
|
||||||
|
progress = kodiutils.progress(message=kodiutils.localize(30723)) # Starting download...
|
||||||
|
|
||||||
|
def callback(total, current):
|
||||||
|
""" Callback function to update the progress bar. """
|
||||||
|
percentage = current * 100 / total
|
||||||
|
progress.update(int(percentage), kodiutils.localize(30724, amount=round(percentage, 2))) # Downloading... ({amount}%)
|
||||||
|
return progress.iscanceled()
|
||||||
|
|
||||||
|
# Resolve the stream and start the download
|
||||||
|
resolved_stream = self._fetch_stream(uuid)
|
||||||
|
status = downloader.download(resolved_stream, download_path, callback)
|
||||||
|
|
||||||
|
# Close the progress bar
|
||||||
|
progress.close()
|
||||||
|
|
||||||
|
if status:
|
||||||
|
kodiutils.ok_dialog(message=kodiutils.localize(30725)) # Download has finished. You can now play this episode from cache.
|
||||||
|
|
||||||
|
def _fetch_stream(self, uuid):
|
||||||
|
""" Fetches the HLS stream of the item.
|
||||||
:type uuid: string
|
:type uuid: string
|
||||||
:type content_type: string
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if we have credentials
|
# Check if we have credentials
|
||||||
@ -106,27 +117,45 @@ class Player:
|
|||||||
message=kodiutils.localize(30701)) # To watch a video, you need to enter your credentials. Do you want to enter them now?
|
message=kodiutils.localize(30701)) # To watch a video, you need to enter your credentials. Do you want to enter them now?
|
||||||
if confirm:
|
if confirm:
|
||||||
kodiutils.open_settings()
|
kodiutils.open_settings()
|
||||||
kodiutils.end_of_directory()
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Fetch an auth token now
|
|
||||||
try:
|
try:
|
||||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
|
||||||
|
|
||||||
# Get stream information
|
# Get stream information
|
||||||
resolved_stream = ContentApi(auth).get_stream_by_uuid(uuid, content_type)
|
resolved_stream = self._api.get_stream_by_uuid(uuid)
|
||||||
return resolved_stream
|
|
||||||
|
|
||||||
except (InvalidLoginException, AuthenticationException) as ex:
|
except (InvalidLoginException, AuthenticationException) as ex:
|
||||||
_LOGGER.exception(ex)
|
_LOGGER.error(ex)
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex)))
|
kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex)))
|
||||||
kodiutils.end_of_directory()
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except GeoblockedException:
|
except GeoblockedException:
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30710)) # This video is geo-blocked...
|
kodiutils.ok_dialog(heading=kodiutils.localize(30709), message=kodiutils.localize(30710)) # This video is geo-blocked...
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except UnavailableException:
|
except UnavailableException:
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable...
|
kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable...
|
||||||
|
return None
|
||||||
|
|
||||||
|
return resolved_stream
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_cached_episode(uuid):
|
||||||
|
""" Check if this episode is available in the download cache.
|
||||||
|
:type uuid: string
|
||||||
|
"""
|
||||||
|
download_folder = kodiutils.get_setting('episode_cache_folder').rstrip('/')
|
||||||
|
if not download_folder or not os.path.exists(download_folder):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if we already have downloaded this file
|
||||||
|
download_path = '%s/%s.mp4' % (download_folder, uuid)
|
||||||
|
if os.path.isfile(download_path):
|
||||||
|
# You have cached this episode. Do you want to play from your cache or stream it?
|
||||||
|
result = kodiutils.yesno_dialog(message=kodiutils.localize(30720),
|
||||||
|
yeslabel=kodiutils.localize(30721), # Stream
|
||||||
|
nolabel=kodiutils.localize(30722)) # Play from cache
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return download_path
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
@ -9,7 +9,7 @@ from resources.lib import kodiutils
|
|||||||
from resources.lib.modules.menu import Menu
|
from resources.lib.modules.menu import Menu
|
||||||
from resources.lib.viervijfzes.search import SearchApi
|
from resources.lib.viervijfzes.search import SearchApi
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('search')
|
||||||
|
|
||||||
|
|
||||||
class Search:
|
class Search:
|
||||||
@ -18,6 +18,7 @@ class Search:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
""" Initialise object """
|
""" Initialise object """
|
||||||
self._search = SearchApi()
|
self._search = SearchApi()
|
||||||
|
self._menu = Menu()
|
||||||
|
|
||||||
def show_search(self, query=None):
|
def show_search(self, query=None):
|
||||||
""" Shows the search dialog
|
""" Shows the search dialog
|
||||||
@ -39,7 +40,7 @@ class Search:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Display results
|
# Display results
|
||||||
listing = [Menu.generate_titleitem(item) for item in items]
|
listing = [self._menu.generate_titleitem(item) for item in items]
|
||||||
|
|
||||||
# Sort like we get our results back.
|
# Sort like we get our results back.
|
||||||
kodiutils.show_listing(listing, 30009, content='tvshows')
|
kodiutils.show_listing(listing, 30009, content='tvshows')
|
||||||
|
@ -8,7 +8,6 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from resources.lib import kodiutils
|
from resources.lib import kodiutils
|
||||||
from resources.lib.kodiutils import TitleItem
|
from resources.lib.kodiutils import TitleItem
|
||||||
from resources.lib.modules.player import Player
|
|
||||||
from resources.lib.viervijfzes import STREAM_DICT
|
from resources.lib.viervijfzes import STREAM_DICT
|
||||||
from resources.lib.viervijfzes.content import UnavailableException
|
from resources.lib.viervijfzes.content import UnavailableException
|
||||||
from resources.lib.viervijfzes.epg import EpgApi
|
from resources.lib.viervijfzes.epg import EpgApi
|
||||||
@ -18,7 +17,7 @@ try: # Python 3
|
|||||||
except ImportError: # Python 2
|
except ImportError: # Python 2
|
||||||
from urllib import quote
|
from urllib import quote
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('tvguide')
|
||||||
|
|
||||||
|
|
||||||
class TvGuide:
|
class TvGuide:
|
||||||
@ -36,8 +35,8 @@ class TvGuide:
|
|||||||
dates = []
|
dates = []
|
||||||
today = datetime.today()
|
today = datetime.today()
|
||||||
|
|
||||||
# The API provides 7 days in the past and 8 days in the future
|
# The API provides 7 days in the past and 13 days in the future
|
||||||
for i in range(-7, 8):
|
for i in range(-7, 13):
|
||||||
day = today + timedelta(days=i)
|
day = today + timedelta(days=i)
|
||||||
|
|
||||||
if i == -1:
|
if i == -1:
|
||||||
@ -71,7 +70,7 @@ class TvGuide:
|
|||||||
|
|
||||||
return dates
|
return dates
|
||||||
|
|
||||||
def show_channel(self, channel):
|
def show_tvguide_channel(self, channel):
|
||||||
""" Shows the dates in the tv guide
|
""" Shows the dates in the tv guide
|
||||||
:type channel: str
|
:type channel: str
|
||||||
"""
|
"""
|
||||||
@ -84,7 +83,7 @@ class TvGuide:
|
|||||||
|
|
||||||
listing.append(
|
listing.append(
|
||||||
TitleItem(title=title,
|
TitleItem(title=title,
|
||||||
path=kodiutils.url_for('show_channel_tvguide_detail', channel=channel, date=day.get('key')),
|
path=kodiutils.url_for('show_tvguide_detail', channel=channel, date=day.get('key')),
|
||||||
art_dict={
|
art_dict={
|
||||||
'icon': 'DefaultYear.png',
|
'icon': 'DefaultYear.png',
|
||||||
'thumb': 'DefaultYear.png',
|
'thumb': 'DefaultYear.png',
|
||||||
@ -97,7 +96,7 @@ class TvGuide:
|
|||||||
|
|
||||||
kodiutils.show_listing(listing, 30013, content='files', sort=['date'])
|
kodiutils.show_listing(listing, 30013, content='files', sort=['date'])
|
||||||
|
|
||||||
def show_detail(self, channel=None, date=None):
|
def show_tvguide_detail(self, channel=None, date=None):
|
||||||
""" Shows the programs of a specific date in the tv guide
|
""" Shows the programs of a specific date in the tv guide
|
||||||
:type channel: str
|
:type channel: str
|
||||||
:type date: str
|
:type date: str
|
||||||
@ -131,7 +130,7 @@ class TvGuide:
|
|||||||
if program.video_url:
|
if program.video_url:
|
||||||
path = kodiutils.url_for('play_from_page', channel=channel, page=quote(program.video_url, safe=''))
|
path = kodiutils.url_for('play_from_page', channel=channel, page=quote(program.video_url, safe=''))
|
||||||
else:
|
else:
|
||||||
path = kodiutils.url_for('play_catalog', uuid='')
|
path = None
|
||||||
title = '[COLOR gray]' + title + '[/COLOR]'
|
title = '[COLOR gray]' + title + '[/COLOR]'
|
||||||
|
|
||||||
stream_dict = STREAM_DICT.copy()
|
stream_dict = STREAM_DICT.copy()
|
||||||
@ -154,7 +153,8 @@ class TvGuide:
|
|||||||
TitleItem(title=title,
|
TitleItem(title=title,
|
||||||
path=path,
|
path=path,
|
||||||
art_dict={
|
art_dict={
|
||||||
'thumb': program.thumb,
|
'icon': program.cover,
|
||||||
|
'thumb': program.cover,
|
||||||
},
|
},
|
||||||
info_dict=info_dict,
|
info_dict=info_dict,
|
||||||
stream_dict=stream_dict,
|
stream_dict=stream_dict,
|
||||||
@ -171,13 +171,9 @@ class TvGuide:
|
|||||||
"""
|
"""
|
||||||
broadcast = self._epg.get_broadcast(channel, timestamp)
|
broadcast = self._epg.get_broadcast(channel, timestamp)
|
||||||
if not broadcast:
|
if not broadcast:
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30713)) # The requested video was not found in the guide.
|
kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30713)) # The requested video was not found in the guide.
|
||||||
kodiutils.end_of_directory()
|
kodiutils.end_of_directory()
|
||||||
return
|
return
|
||||||
|
|
||||||
if not broadcast.video_url:
|
kodiutils.container_update(
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable and can't be played right now.
|
kodiutils.url_for('play', uuid=broadcast.video_url))
|
||||||
kodiutils.end_of_directory()
|
|
||||||
return
|
|
||||||
|
|
||||||
Player().play_from_page(broadcast.video_url)
|
|
||||||
|
@ -5,13 +5,16 @@ from __future__ import absolute_import, division, unicode_literals
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
from time import time
|
||||||
|
|
||||||
from xbmc import Monitor, Player, getInfoLabel
|
from xbmc import Monitor
|
||||||
|
|
||||||
from resources.lib import kodilogging, kodiutils
|
from resources.lib import kodilogging, kodiutils
|
||||||
from resources.lib.viervijfzes.auth import AuthApi
|
from resources.lib.viervijfzes.auth import AuthApi
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
kodilogging.config()
|
||||||
|
_LOGGER = logging.getLogger('service')
|
||||||
|
|
||||||
|
|
||||||
class BackgroundService(Monitor):
|
class BackgroundService(Monitor):
|
||||||
@ -22,23 +25,26 @@ class BackgroundService(Monitor):
|
|||||||
self.update_interval = 24 * 3600 # Every 24 hours
|
self.update_interval = 24 * 3600 # Every 24 hours
|
||||||
self.cache_expiry = 30 * 24 * 3600 # One month
|
self.cache_expiry = 30 * 24 * 3600 # One month
|
||||||
self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
self._auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||||
self._kodiplayer = KodiPlayer()
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
""" Background loop for maintenance tasks """
|
""" Background loop for maintenance tasks """
|
||||||
_LOGGER.debug('Service started')
|
_LOGGER.info('Service started')
|
||||||
|
|
||||||
while not self.abortRequested():
|
while not self.abortRequested():
|
||||||
|
# Update every `update_interval` after the last update
|
||||||
|
if kodiutils.get_setting_bool('metadata_update') and int(kodiutils.get_setting('metadata_last_updated', 0)) + self.update_interval < time():
|
||||||
|
self._update_metadata()
|
||||||
|
|
||||||
# Stop when abort requested
|
# Stop when abort requested
|
||||||
if self.waitForAbort(10):
|
if self.waitForAbort(10):
|
||||||
break
|
break
|
||||||
|
|
||||||
_LOGGER.debug('Service stopped')
|
_LOGGER.info('Service stopped')
|
||||||
|
|
||||||
def onSettingsChanged(self): # pylint: disable=invalid-name
|
def onSettingsChanged(self): # pylint: disable=invalid-name
|
||||||
""" Callback when a setting has changed """
|
""" Callback when a setting has changed """
|
||||||
if self._has_credentials_changed():
|
if self._has_credentials_changed():
|
||||||
_LOGGER.debug('Clearing auth tokens due to changed credentials')
|
_LOGGER.info('Clearing auth tokens due to changed credentials')
|
||||||
self._auth.clear_tokens()
|
self._auth.clear_tokens()
|
||||||
|
|
||||||
# Refresh container
|
# Refresh container
|
||||||
@ -56,90 +62,39 @@ class BackgroundService(Monitor):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _update_metadata(self):
|
||||||
|
""" Update the metadata for the listings """
|
||||||
|
from resources.lib.modules.metadata import Metadata
|
||||||
|
|
||||||
class KodiPlayer(Player):
|
def update_status(_i, _total):
|
||||||
"""Communication with Kodi Player"""
|
""" Allow to cancel the background job """
|
||||||
|
return self.abortRequested() or not kodiutils.get_setting_bool('metadata_update')
|
||||||
|
|
||||||
def __init__(self):
|
# Clear metadata that has expired for 30 days
|
||||||
"""KodiPlayer initialisation"""
|
self._remove_expired_metadata(30 * 24 * 60 * 60)
|
||||||
Player.__init__(self)
|
|
||||||
self.listen = False
|
|
||||||
self.path = None
|
|
||||||
self.av_started = False
|
|
||||||
self.stream_path = None
|
|
||||||
|
|
||||||
def onPlayBackStarted(self): # pylint: disable=invalid-name
|
# Fetch new metadata
|
||||||
"""Called when user starts playing a file"""
|
success = Metadata().fetch_metadata(callback=update_status)
|
||||||
self.path = getInfoLabel('Player.FilenameAndPath')
|
|
||||||
if self.path.startswith('plugin://plugin.video.viervijfzes/'):
|
# Update metadata_last_updated
|
||||||
self.listen = True
|
if success:
|
||||||
else:
|
kodiutils.set_setting('metadata_last_updated', str(int(time())))
|
||||||
self.listen = False
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_expired_metadata(keep_expired=None):
|
||||||
|
""" Clear the cache """
|
||||||
|
path = kodiutils.get_cache_path()
|
||||||
|
if not os.path.exists(path):
|
||||||
return
|
return
|
||||||
_LOGGER.debug('KodiPlayer onPlayBackStarted')
|
|
||||||
self.av_started = False
|
|
||||||
self.stream_path = self.getPlayingFile()
|
|
||||||
|
|
||||||
def onAVStarted(self): # pylint: disable=invalid-name
|
now = time()
|
||||||
"""Called when Kodi has a video or audiostream"""
|
for filename in os.listdir(path):
|
||||||
if not self.listen:
|
fullpath = path + filename
|
||||||
return
|
if keep_expired and os.stat(fullpath).st_mtime + keep_expired > now:
|
||||||
_LOGGER.debug('KodiPlayer onAVStarted')
|
continue
|
||||||
self.av_started = True
|
os.unlink(fullpath)
|
||||||
|
|
||||||
def onAVChange(self): # pylint: disable=invalid-name
|
|
||||||
"""Called when Kodi has a video, audio or subtitle stream. Also happens when the stream changes."""
|
|
||||||
if not self.listen:
|
|
||||||
return
|
|
||||||
_LOGGER.debug('KodiPlayer onAVChange')
|
|
||||||
|
|
||||||
def onPlayBackSeek(self, time, seekOffset): # pylint: disable=invalid-name, redefined-outer-name
|
|
||||||
"""Called when user seeks to a time"""
|
|
||||||
if not self.listen:
|
|
||||||
return
|
|
||||||
_LOGGER.debug('KodiPlayer onPlayBackSeek time=%s offset=%s', time, seekOffset)
|
|
||||||
|
|
||||||
def onPlayBackPaused(self): # pylint: disable=invalid-name
|
|
||||||
"""Called when user pauses a playing file"""
|
|
||||||
if not self.listen:
|
|
||||||
return
|
|
||||||
_LOGGER.debug('KodiPlayer onPlayBackPaused')
|
|
||||||
|
|
||||||
def onPlayBackResumed(self): # pylint: disable=invalid-name
|
|
||||||
"""Called when user resumes a paused file or a next playlist item is started"""
|
|
||||||
if not self.listen:
|
|
||||||
return
|
|
||||||
_LOGGER.debug('KodiPlayer onPlayBackResumed')
|
|
||||||
|
|
||||||
def onPlayBackError(self): # pylint: disable=invalid-name
|
|
||||||
"""Called when playback stops due to an error"""
|
|
||||||
if not self.listen:
|
|
||||||
return
|
|
||||||
_LOGGER.debug('KodiPlayer onPlayBackError')
|
|
||||||
|
|
||||||
def onPlayBackStopped(self): # pylint: disable=invalid-name
|
|
||||||
"""Called when user stops Kodi playing a file"""
|
|
||||||
if not self.listen:
|
|
||||||
return
|
|
||||||
_LOGGER.debug('KodiPlayer onPlayBackStopped')
|
|
||||||
if not self.av_started:
|
|
||||||
# Check stream path
|
|
||||||
import requests
|
|
||||||
response = requests.get(self.stream_path, timeout=5)
|
|
||||||
if response.status_code == 403:
|
|
||||||
message_id = 30720
|
|
||||||
else:
|
|
||||||
message_id = 30719
|
|
||||||
kodiutils.ok_dialog(message=kodiutils.localize(message_id))
|
|
||||||
|
|
||||||
def onPlayBackEnded(self): # pylint: disable=invalid-name
|
|
||||||
"""Called when Kodi has ended playing a file"""
|
|
||||||
if not self.listen:
|
|
||||||
return
|
|
||||||
_LOGGER.debug('KodiPlayer onPlayBackEnded')
|
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
""" Run the BackgroundService """
|
""" Run the BackgroundService """
|
||||||
kodilogging.config()
|
|
||||||
BackgroundService().run()
|
BackgroundService().run()
|
||||||
|
@ -1,73 +1,46 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
""" GoPlay API """
|
""" SBS API """
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
CHANNELS = OrderedDict([
|
CHANNELS = OrderedDict([
|
||||||
('Play4', {
|
('vier', dict(
|
||||||
'name': 'Play4',
|
name='VIER',
|
||||||
'url': 'live-kijken/play-4',
|
url='https://www.vier.be',
|
||||||
'epg_id': 'vier',
|
logo='vier.png',
|
||||||
'logo': 'play4.png',
|
background='vier-background.jpg',
|
||||||
'background': 'play4-background.png',
|
studio_icon='vier',
|
||||||
'iptv_preset': 4,
|
youtube=[
|
||||||
'iptv_id': 'play4.be',
|
dict(
|
||||||
'youtube': [
|
label='VIER / VIJF',
|
||||||
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
|
logo='vier.png',
|
||||||
]
|
path='plugin://plugin.video.youtube/user/viertv/',
|
||||||
}),
|
),
|
||||||
('Play5', {
|
],
|
||||||
'name': 'Play5',
|
)),
|
||||||
'url': 'live-kijken/play-5',
|
('vijf', dict(
|
||||||
'epg_id': 'vijf',
|
name='VIJF',
|
||||||
'logo': 'play5.png',
|
url='https://www.vijf.be',
|
||||||
'background': 'play5-background.png',
|
logo='vijf.png',
|
||||||
'iptv_preset': 5,
|
background='vijf-background.jpg',
|
||||||
'iptv_id': 'play5.be',
|
studio_icon='vijf',
|
||||||
'youtube': [
|
youtube=[
|
||||||
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
|
dict(
|
||||||
]
|
label='VIER / VIJF',
|
||||||
}),
|
logo='vijf.png',
|
||||||
('Play6', {
|
path='plugin://plugin.video.youtube/user/viertv/',
|
||||||
'name': 'Play6',
|
),
|
||||||
'url': 'live-kijken/play-6',
|
],
|
||||||
'epg_id': 'zes',
|
)),
|
||||||
'logo': 'play6.png',
|
('zes', dict(
|
||||||
'background': 'play6-background.png',
|
name='ZES',
|
||||||
'iptv_preset': 6,
|
url='https://www.zestv.be',
|
||||||
'iptv_id': 'play6.be',
|
logo='zes.png',
|
||||||
'youtube': [
|
background='zes-background.jpg',
|
||||||
{'label': 'GoPlay', 'logo': 'goplay.png', 'path': 'plugin://plugin.video.youtube/user/viertv/'},
|
studio_icon='zes',
|
||||||
]
|
youtube=[],
|
||||||
}),
|
))
|
||||||
('Play7', {
|
|
||||||
'name': 'Play7',
|
|
||||||
'url': 'live-kijken/play-7',
|
|
||||||
'epg_id': 'zeven',
|
|
||||||
'logo': 'play7.png',
|
|
||||||
'background': 'play7-background.png',
|
|
||||||
'iptv_preset': 17,
|
|
||||||
'iptv_id': 'play7.be',
|
|
||||||
'youtube': []
|
|
||||||
}),
|
|
||||||
('PlayCrime', {
|
|
||||||
'name': 'PlayCrime',
|
|
||||||
'url': 'live-kijken/play-crime',
|
|
||||||
'epg_id': 'crime',
|
|
||||||
'logo': 'playcrime.png',
|
|
||||||
'background': 'playcrime-background.png',
|
|
||||||
'iptv_preset': 18,
|
|
||||||
'iptv_id': 'playcrime.be',
|
|
||||||
'youtube': []
|
|
||||||
}),
|
|
||||||
('GoPlay', {
|
|
||||||
'name': 'Go Play',
|
|
||||||
'url': 'https://www.goplay.be',
|
|
||||||
'logo': 'goplay.png',
|
|
||||||
'background': 'goplay-background.png',
|
|
||||||
'youtube': []
|
|
||||||
})
|
|
||||||
])
|
])
|
||||||
|
|
||||||
STREAM_DICT = {
|
STREAM_DICT = {
|
||||||
@ -75,22 +48,3 @@ STREAM_DICT = {
|
|||||||
'height': 544,
|
'height': 544,
|
||||||
'width': 960,
|
'width': 960,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ResolvedStream:
|
|
||||||
""" Defines a stream that we can play"""
|
|
||||||
|
|
||||||
def __init__(self, uuid=None, url=None, stream_type=None, license_key=None):
|
|
||||||
"""
|
|
||||||
:type uuid: str
|
|
||||||
:type url: str
|
|
||||||
:type stream_type: str
|
|
||||||
:type license_key: str
|
|
||||||
"""
|
|
||||||
self.uuid = uuid
|
|
||||||
self.url = url
|
|
||||||
self.stream_type = stream_type
|
|
||||||
self.license_key = license_key
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "%r" % self.__dict__
|
|
||||||
|
@ -9,19 +9,16 @@ import os
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
from resources.lib import kodiutils
|
||||||
from resources.lib.viervijfzes.aws.cognito_identity import CognitoIdentity
|
from resources.lib.viervijfzes.auth_awsidp import AwsIdp, InvalidLoginException, AuthenticationException
|
||||||
from resources.lib.viervijfzes.aws.cognito_idp import AuthenticationException, CognitoIdp, InvalidLoginException
|
|
||||||
from resources.lib.viervijfzes.aws.cognito_sync import CognitoSync
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('auth-api')
|
||||||
|
|
||||||
|
|
||||||
class AuthApi:
|
class AuthApi:
|
||||||
""" GoPlay Authentication API """
|
""" VIER/VIJF/ZES Authentication API """
|
||||||
COGNITO_REGION = 'eu-west-1'
|
COGNITO_REGION = 'eu-west-1'
|
||||||
COGNITO_POOL_ID = 'eu-west-1_dViSsKM5Y'
|
COGNITO_POOL_ID = 'eu-west-1_dViSsKM5Y'
|
||||||
COGNITO_CLIENT_ID = '6s1h851s8uplco5h6mqh1jac8m'
|
COGNITO_CLIENT_ID = '6s1h851s8uplco5h6mqh1jac8m'
|
||||||
COGNITO_IDENTITY_POOL_ID = 'eu-west-1:8b7eb22c-cf61-43d5-a624-04b494867234'
|
|
||||||
|
|
||||||
TOKEN_FILE = 'auth-tokens.json'
|
TOKEN_FILE = 'auth-tokens.json'
|
||||||
|
|
||||||
@ -36,13 +33,13 @@ class AuthApi:
|
|||||||
|
|
||||||
# Load tokens from cache
|
# Load tokens from cache
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(self._token_path, self.TOKEN_FILE), 'r') as fdesc:
|
with open(self._token_path + self.TOKEN_FILE, 'r') as fdesc:
|
||||||
data_json = json.loads(fdesc.read())
|
data_json = json.loads(fdesc.read())
|
||||||
self._id_token = data_json.get('id_token')
|
self._id_token = data_json.get('id_token')
|
||||||
self._refresh_token = data_json.get('refresh_token')
|
self._refresh_token = data_json.get('refresh_token')
|
||||||
self._expiry = int(data_json.get('expiry', 0))
|
self._expiry = int(data_json.get('expiry', 0))
|
||||||
except (IOError, TypeError, ValueError):
|
except (IOError, TypeError, ValueError):
|
||||||
_LOGGER.warning('We could not use the cache since it is invalid or non-existent.')
|
_LOGGER.info('We could not use the cache since it is invalid or non-existent.')
|
||||||
|
|
||||||
def get_token(self):
|
def get_token(self):
|
||||||
""" Get a valid token """
|
""" Get a valid token """
|
||||||
@ -77,55 +74,30 @@ class AuthApi:
|
|||||||
|
|
||||||
# Store new tokens in cache
|
# Store new tokens in cache
|
||||||
if not os.path.exists(self._token_path):
|
if not os.path.exists(self._token_path):
|
||||||
os.makedirs(self._token_path)
|
os.mkdir(self._token_path)
|
||||||
with open(os.path.join(self._token_path, self.TOKEN_FILE), 'w') as fdesc:
|
with open(self._token_path + self.TOKEN_FILE, 'w') as fdesc:
|
||||||
data = json.dumps({
|
data = json.dumps(dict(
|
||||||
'id_token': self._id_token,
|
id_token=self._id_token,
|
||||||
'refresh_token': self._refresh_token,
|
refresh_token=self._refresh_token,
|
||||||
'expiry': self._expiry
|
expiry=self._expiry,
|
||||||
})
|
))
|
||||||
fdesc.write(kodiutils.from_unicode(data))
|
fdesc.write(kodiutils.from_unicode(data))
|
||||||
|
|
||||||
return self._id_token
|
return self._id_token
|
||||||
|
|
||||||
def clear_tokens(self):
|
def clear_tokens(self):
|
||||||
""" Remove the cached tokens. """
|
""" Remove the cached tokens. """
|
||||||
if os.path.exists(os.path.join(self._token_path, AuthApi.TOKEN_FILE)):
|
if os.path.exists(self._token_path + AuthApi.TOKEN_FILE):
|
||||||
os.unlink(os.path.join(self._token_path, AuthApi.TOKEN_FILE))
|
os.unlink(self._token_path + AuthApi.TOKEN_FILE)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _authenticate(username, password):
|
def _authenticate(username, password):
|
||||||
""" Authenticate with Amazon Cognito and fetch a refresh token and id token. """
|
""" Authenticate with Amazon Cognito and fetch a refresh token and id token. """
|
||||||
idp_client = CognitoIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
|
client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
|
||||||
return idp_client.authenticate(username, password)
|
return client.authenticate(username, password)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _refresh(refresh_token):
|
def _refresh(refresh_token):
|
||||||
""" Use the refresh token to fetch a new id token. """
|
""" Use the refresh token to fetch a new id token. """
|
||||||
idp_client = CognitoIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
|
client = AwsIdp(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_CLIENT_ID)
|
||||||
return idp_client.renew_token(refresh_token)
|
return client.renew_token(refresh_token)
|
||||||
|
|
||||||
def get_dataset(self, dataset, key):
|
|
||||||
""" Fetch the value from the specified dataset. """
|
|
||||||
identity_client = CognitoIdentity(AuthApi.COGNITO_POOL_ID, AuthApi.COGNITO_IDENTITY_POOL_ID)
|
|
||||||
id_token = self.get_token()
|
|
||||||
identity_id = identity_client.get_id(id_token)
|
|
||||||
credentials = identity_client.get_credentials_for_identity(id_token, identity_id)
|
|
||||||
|
|
||||||
sync_client = CognitoSync(AuthApi.COGNITO_IDENTITY_POOL_ID, identity_id, credentials)
|
|
||||||
data, session_token, sync_count = sync_client.list_records(dataset, key)
|
|
||||||
|
|
||||||
sync_info = {
|
|
||||||
'identity_id': identity_id,
|
|
||||||
'credentials': credentials,
|
|
||||||
'session_token': session_token,
|
|
||||||
'sync_count': sync_count,
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, sync_info
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def put_dataset(dataset, key, value, sync_info):
|
|
||||||
""" Store the value from the specified dataset. """
|
|
||||||
sync_client = CognitoSync(AuthApi.COGNITO_IDENTITY_POOL_ID, sync_info.get('identity_id'), sync_info.get('credentials'))
|
|
||||||
sync_client.update_records(dataset, key, value, sync_info.get('session_token'), sync_info.get('sync_count'))
|
|
||||||
|
@ -12,11 +12,12 @@ import hmac
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import six
|
import six
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('auth-awsidp')
|
||||||
|
|
||||||
|
|
||||||
class InvalidLoginException(Exception):
|
class InvalidLoginException(Exception):
|
||||||
@ -27,14 +28,11 @@ class AuthenticationException(Exception):
|
|||||||
""" Something went wrong while logging in """
|
""" Something went wrong while logging in """
|
||||||
|
|
||||||
|
|
||||||
class CognitoIdp:
|
class AwsIdp:
|
||||||
""" Cognito IDP """
|
""" AWS Identity Provider """
|
||||||
|
|
||||||
def __init__(self, pool_id, client_id):
|
def __init__(self, pool_id, client_id):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
See https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/Welcome.html.
|
|
||||||
|
|
||||||
:param str pool_id: The AWS user pool to connect to (format: <region>_<poolid>).
|
:param str pool_id: The AWS user pool to connect to (format: <region>_<poolid>).
|
||||||
E.g.: eu-west-1_aLkOfYN3T
|
E.g.: eu-west-1_aLkOfYN3T
|
||||||
:param str client_id: The client application ID (the ID of the application connecting)
|
:param str client_id: The client application ID (the ID of the application connecting)
|
||||||
@ -77,6 +75,7 @@ class CognitoIdp:
|
|||||||
self.k = self.__hex_to_long(self.__hex_hash('00' + self.n_hex + '0' + self.g_hex)) # pylint: disable=invalid-name
|
self.k = self.__hex_to_long(self.__hex_hash('00' + self.n_hex + '0' + self.g_hex)) # pylint: disable=invalid-name
|
||||||
self.small_a_value = self.__generate_random_small_a()
|
self.small_a_value = self.__generate_random_small_a()
|
||||||
self.large_a_value = self.__calculate_a()
|
self.large_a_value = self.__calculate_a()
|
||||||
|
_LOGGER.debug("Created %s", self)
|
||||||
|
|
||||||
def authenticate(self, username, password):
|
def authenticate(self, username, password):
|
||||||
""" Authenticate with a username and password. """
|
""" Authenticate with a username and password. """
|
||||||
@ -293,7 +292,7 @@ class CognitoIdp:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __hex_hash(hex_string):
|
def __hex_hash(hex_string):
|
||||||
return CognitoIdp.__hash_sha256(bytearray.fromhex(hex_string))
|
return AwsIdp.__hash_sha256(bytearray.fromhex(hex_string))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __hash_sha256(buf):
|
def __hash_sha256(buf):
|
||||||
@ -313,7 +312,7 @@ class CognitoIdp:
|
|||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
if not isinstance(long_int, six.string_types):
|
if not isinstance(long_int, six.string_types):
|
||||||
hash_str = CognitoIdp.__long_to_hex(long_int)
|
hash_str = AwsIdp.__long_to_hex(long_int)
|
||||||
else:
|
else:
|
||||||
hash_str = long_int
|
hash_str = long_int
|
||||||
if len(hash_str) % 2 == 1:
|
if len(hash_str) % 2 == 1:
|
||||||
@ -325,7 +324,7 @@ class CognitoIdp:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_random(nbytes):
|
def __get_random(nbytes):
|
||||||
random_hex = binascii.hexlify(os.urandom(nbytes))
|
random_hex = binascii.hexlify(os.urandom(nbytes))
|
||||||
return CognitoIdp.__hex_to_long(random_hex)
|
return AwsIdp.__hex_to_long(random_hex)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_current_timestamp():
|
def __get_current_timestamp():
|
||||||
@ -341,7 +340,11 @@ class CognitoIdp:
|
|||||||
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
|
||||||
time_now = datetime.datetime.utcnow()
|
time_now = datetime.datetime.utcnow()
|
||||||
format_string = "{} {} {} %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month], time_now.day)
|
if sys.platform.startswith('win'):
|
||||||
|
format_string = "{} {} %#d %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month])
|
||||||
|
else:
|
||||||
|
format_string = "{} {} %-d %H:%M:%S UTC %Y".format(days[time_now.weekday()], months[time_now.month])
|
||||||
|
|
||||||
time_string = datetime.datetime.utcnow().strftime(format_string)
|
time_string = datetime.datetime.utcnow().strftime(format_string)
|
||||||
return time_string
|
return time_string
|
||||||
|
|
@ -1,69 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
""" Amazon Cognito Identity implementation without external dependencies """
|
|
||||||
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CognitoIdentity:
|
|
||||||
""" Cognito Identity """
|
|
||||||
|
|
||||||
def __init__(self, pool_id, identity_pool_id):
|
|
||||||
"""
|
|
||||||
|
|
||||||
See https://docs.aws.amazon.com/cognitoidentity/latest/APIReference/Welcome.html.
|
|
||||||
|
|
||||||
:param str pool_id:
|
|
||||||
:param str identity_pool_id:
|
|
||||||
"""
|
|
||||||
self.pool_id = pool_id
|
|
||||||
if "_" not in self.pool_id:
|
|
||||||
raise ValueError("Invalid pool_id format. Should be <region>_<poolid>.")
|
|
||||||
|
|
||||||
self.identity_pool_id = identity_pool_id
|
|
||||||
self.region = self.pool_id.split("_")[0]
|
|
||||||
self.url = "https://cognito-identity.%s.amazonaws.com/" % self.region
|
|
||||||
self._session = requests.session()
|
|
||||||
|
|
||||||
def get_id(self, id_token):
|
|
||||||
""" Get the Identity ID based on the id_token. """
|
|
||||||
provider = 'cognito-idp.%s.amazonaws.com/%s' % (self.region, self.pool_id)
|
|
||||||
data = {
|
|
||||||
"IdentityPoolId": self.identity_pool_id,
|
|
||||||
"Logins": {
|
|
||||||
provider: id_token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response = self._session.post(self.url, json=data, headers={
|
|
||||||
'x-amz-target': 'AWSCognitoIdentityService.GetId',
|
|
||||||
'content-type': 'application/x-amz-json-1.1',
|
|
||||||
})
|
|
||||||
|
|
||||||
result = json.loads(response.text)
|
|
||||||
|
|
||||||
return result.get('IdentityId')
|
|
||||||
|
|
||||||
def get_credentials_for_identity(self, id_token, identity_id):
|
|
||||||
""" Get credentials based on the id_token and identity_id. """
|
|
||||||
provider = 'cognito-idp.%s.amazonaws.com/%s' % (self.region, self.pool_id)
|
|
||||||
data = {
|
|
||||||
"IdentityId": identity_id,
|
|
||||||
"Logins": {
|
|
||||||
provider: id_token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self._session.post(self.url, json=data, headers={
|
|
||||||
'x-amz-target': 'AWSCognitoIdentityService.GetCredentialsForIdentity',
|
|
||||||
'content-type': 'application/x-amz-json-1.1',
|
|
||||||
})
|
|
||||||
|
|
||||||
result = json.loads(response.text)
|
|
||||||
|
|
||||||
return result.get('Credentials')
|
|
@ -1,194 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
""" Amazon Cognito Sync implementation without external dependencies """
|
|
||||||
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
try: # Python 3
|
|
||||||
from urllib.parse import quote, urlparse
|
|
||||||
except ImportError: # Python 2
|
|
||||||
from urllib import quote
|
|
||||||
|
|
||||||
from urlparse import urlparse
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CognitoSync:
|
|
||||||
""" Amazon Cognito Sync """
|
|
||||||
|
|
||||||
def __init__(self, identity_pool_id, identity_id, credentials):
|
|
||||||
"""
|
|
||||||
|
|
||||||
See https://docs.aws.amazon.com/cognitosync/latest/APIReference/Welcome.html.
|
|
||||||
|
|
||||||
:param str identity_pool_id:
|
|
||||||
:param str identity_id:
|
|
||||||
:param dict credentials:
|
|
||||||
"""
|
|
||||||
self.identity_pool_id = identity_pool_id
|
|
||||||
self.identity_id = identity_id
|
|
||||||
self.credentials = credentials
|
|
||||||
|
|
||||||
self.region = self.identity_pool_id.split(":")[0]
|
|
||||||
self.url = "https://cognito-sync.%s.amazonaws.com" % self.region
|
|
||||||
self._session = requests.session()
|
|
||||||
|
|
||||||
def _sign(self, request, service='cognito-sync'):
|
|
||||||
""" Sign the request.
|
|
||||||
|
|
||||||
More info at https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html.
|
|
||||||
|
|
||||||
:param requests.PreparedRequest request: A prepared request that should be signed.
|
|
||||||
:param str service: The service where this request is going to.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def sign(key, msg):
|
|
||||||
""" Sign this message. """
|
|
||||||
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
|
|
||||||
|
|
||||||
def get_signature_key(key, date_stamp, region_name, service_name):
|
|
||||||
""" Generate a signature key. """
|
|
||||||
k_date = sign(('AWS4' + key).encode('utf-8'), date_stamp)
|
|
||||||
k_region = sign(k_date, region_name)
|
|
||||||
k_service = sign(k_region, service_name)
|
|
||||||
k_signing = sign(k_service, 'aws4_request')
|
|
||||||
return k_signing
|
|
||||||
|
|
||||||
# Parse the URL
|
|
||||||
url_parsed = urlparse(request.url)
|
|
||||||
|
|
||||||
# Create a date for headers and the credential string
|
|
||||||
now = datetime.datetime.utcnow()
|
|
||||||
amzdate = now.strftime('%Y%m%dT%H%M%SZ')
|
|
||||||
datestamp = now.strftime('%Y%m%d') # Date w/o time, used in credential scope
|
|
||||||
|
|
||||||
# Step 1. Create a canonical request
|
|
||||||
canonical_uri = quote(url_parsed.path)
|
|
||||||
canonical_querystring = url_parsed.query # TODO: sort when using multiple values
|
|
||||||
canonical_headers = ('host:' + url_parsed.netloc + '\n' +
|
|
||||||
'x-amz-date:' + amzdate + '\n')
|
|
||||||
signed_headers = 'host;x-amz-date'
|
|
||||||
|
|
||||||
if request.body:
|
|
||||||
payload_hash = hashlib.sha256(request.body).hexdigest()
|
|
||||||
else:
|
|
||||||
# SHA256 of empty string
|
|
||||||
payload_hash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
|
||||||
|
|
||||||
canonical_request = (request.method + '\n' +
|
|
||||||
canonical_uri + '\n' +
|
|
||||||
canonical_querystring + '\n' +
|
|
||||||
canonical_headers + '\n' +
|
|
||||||
signed_headers + '\n' +
|
|
||||||
payload_hash)
|
|
||||||
|
|
||||||
# Step 2. Create a string to sign
|
|
||||||
algorithm = 'AWS4-HMAC-SHA256'
|
|
||||||
credential_scope = '%s/%s/%s/%s' % (datestamp, self.region, service, 'aws4_request')
|
|
||||||
string_to_sign = (algorithm + '\n' +
|
|
||||||
amzdate + '\n' +
|
|
||||||
credential_scope + '\n' +
|
|
||||||
hashlib.sha256(canonical_request.encode('utf-8')).hexdigest())
|
|
||||||
signing_key = get_signature_key(self.credentials.get('SecretKey'), datestamp, self.region, service)
|
|
||||||
|
|
||||||
# Step 3. Calculate the signature
|
|
||||||
signature = hmac.new(signing_key, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
authorization_header = '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s' % (
|
|
||||||
algorithm, self.credentials.get('AccessKeyId'), credential_scope, signed_headers, signature)
|
|
||||||
|
|
||||||
# Step 4. Add the signature to the request
|
|
||||||
request.headers.update({
|
|
||||||
'x-amz-date': amzdate,
|
|
||||||
'Authorization': authorization_header
|
|
||||||
})
|
|
||||||
|
|
||||||
def list_records(self, dataset, key):
|
|
||||||
""" Return the values of this dataset.
|
|
||||||
|
|
||||||
:param str dataset: The name of the dataset to request.
|
|
||||||
:param str key: The name of the key to request.
|
|
||||||
:return The requested dataset
|
|
||||||
:rtype: dict
|
|
||||||
"""
|
|
||||||
# Prepare the request
|
|
||||||
request = requests.Request(
|
|
||||||
method='GET',
|
|
||||||
params={
|
|
||||||
'maxResults': 1024,
|
|
||||||
},
|
|
||||||
url=self.url + '/identitypools/{identity_pool_id}/identities/{identity_id}/datasets/{dataset}/records'.format(
|
|
||||||
identity_pool_id=self.identity_pool_id,
|
|
||||||
identity_id=self.identity_id,
|
|
||||||
dataset=dataset
|
|
||||||
),
|
|
||||||
headers={
|
|
||||||
'x-amz-security-token': self.credentials.get('SessionToken'),
|
|
||||||
}).prepare()
|
|
||||||
|
|
||||||
# Sign the request
|
|
||||||
self._sign(request)
|
|
||||||
|
|
||||||
# Send the request
|
|
||||||
reply = self._session.send(request)
|
|
||||||
reply.raise_for_status()
|
|
||||||
result = json.loads(reply.text)
|
|
||||||
|
|
||||||
_LOGGER.debug("Got results: %s", result.get('Records'))
|
|
||||||
|
|
||||||
# Return the records
|
|
||||||
try:
|
|
||||||
record = next(record for record in result.get('Records', []) if record.get('Key') == key)
|
|
||||||
value = json.loads(record.get('Value'))
|
|
||||||
sync_count = record.get('SyncCount')
|
|
||||||
except StopIteration:
|
|
||||||
value = None
|
|
||||||
sync_count = 0
|
|
||||||
|
|
||||||
return value, result.get('SyncSessionToken'), sync_count
|
|
||||||
|
|
||||||
def update_records(self, dataset, key, value, session_token, sync_count):
|
|
||||||
""" Return the values of this dataset.
|
|
||||||
|
|
||||||
:param str dataset: The name of the dataset to request.
|
|
||||||
:param any value: The value.
|
|
||||||
:param str session_token: The session token from the list_records call.
|
|
||||||
:param int sync_count: The last SyncCount value, so we refuse race conditions.
|
|
||||||
"""
|
|
||||||
# Prepare the request
|
|
||||||
request = requests.Request(
|
|
||||||
method='POST',
|
|
||||||
url=self.url + '/identitypools/{identity_pool_id}/identities/{identity_id}/datasets/{dataset}'.format(
|
|
||||||
identity_pool_id=self.identity_pool_id,
|
|
||||||
identity_id=self.identity_id,
|
|
||||||
dataset=dataset
|
|
||||||
),
|
|
||||||
headers={
|
|
||||||
'x-amz-security-token': self.credentials.get('SessionToken'),
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
"SyncSessionToken": session_token,
|
|
||||||
"RecordPatches": [
|
|
||||||
{
|
|
||||||
"Key": key,
|
|
||||||
"Op": "replace",
|
|
||||||
"SyncCount": sync_count,
|
|
||||||
"Value": json.dumps(value),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}).prepare()
|
|
||||||
|
|
||||||
# Sign the request
|
|
||||||
self._sign(request)
|
|
||||||
|
|
||||||
# Send the request
|
|
||||||
reply = self._session.send(request)
|
|
||||||
reply.raise_for_status()
|
|
@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -11,27 +10,17 @@ import re
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from six.moves.html_parser import HTMLParser
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
from resources.lib.viervijfzes import CHANNELS
|
||||||
from resources.lib.kodiutils import STREAM_DASH, STREAM_HLS, html_to_kodi
|
|
||||||
from resources.lib.viervijfzes import ResolvedStream
|
|
||||||
|
|
||||||
try: # Python 3
|
_LOGGER = logging.getLogger('content-api')
|
||||||
from html import unescape
|
|
||||||
except ImportError: # Python 2
|
|
||||||
from HTMLParser import HTMLParser
|
|
||||||
|
|
||||||
unescape = HTMLParser().unescape
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
CACHE_AUTO = 1 # Allow to use the cache, and query the API if no cache is available
|
CACHE_AUTO = 1 # Allow to use the cache, and query the API if no cache is available
|
||||||
CACHE_ONLY = 2 # Only use the cache, don't use the API
|
CACHE_ONLY = 2 # Only use the cache, don't use the API
|
||||||
CACHE_PREVENT = 3 # Don't use the cache
|
CACHE_PREVENT = 3 # Don't use the cache
|
||||||
|
|
||||||
PROXIES = kodiutils.get_proxies()
|
|
||||||
|
|
||||||
|
|
||||||
class UnavailableException(Exception):
|
class UnavailableException(Exception):
|
||||||
""" Is thrown when an item is unavailable. """
|
""" Is thrown when an item is unavailable. """
|
||||||
@ -48,9 +37,7 @@ class GeoblockedException(Exception):
|
|||||||
class Program:
|
class Program:
|
||||||
""" Defines a Program. """
|
""" Defines a Program. """
|
||||||
|
|
||||||
def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, poster=None, thumb=None, fanart=None, seasons=None,
|
def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, aired=None, cover=None, background=None, seasons=None, episodes=None):
|
||||||
episodes=None,
|
|
||||||
clips=None, my_list=False):
|
|
||||||
"""
|
"""
|
||||||
:type uuid: str
|
:type uuid: str
|
||||||
:type path: str
|
:type path: str
|
||||||
@ -58,13 +45,10 @@ class Program:
|
|||||||
:type title: str
|
:type title: str
|
||||||
:type description: str
|
:type description: str
|
||||||
:type aired: datetime
|
:type aired: datetime
|
||||||
:type poster: str
|
:type cover: str
|
||||||
:type thumb: str
|
:type background: str
|
||||||
:type fanart: str
|
|
||||||
:type seasons: list[Season]
|
:type seasons: list[Season]
|
||||||
:type episodes: list[Episode]
|
:type episodes: list[Episode]
|
||||||
:type clips: list[Episode]
|
|
||||||
:type my_list: bool
|
|
||||||
"""
|
"""
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.path = path
|
self.path = path
|
||||||
@ -72,13 +56,10 @@ class Program:
|
|||||||
self.title = title
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
self.aired = aired
|
self.aired = aired
|
||||||
self.poster = poster
|
self.cover = cover
|
||||||
self.thumb = thumb
|
self.background = background
|
||||||
self.fanart = fanart
|
|
||||||
self.seasons = seasons
|
self.seasons = seasons
|
||||||
self.episodes = episodes
|
self.episodes = episodes
|
||||||
self.clips = clips
|
|
||||||
self.my_list = my_list
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "%r" % self.__dict__
|
return "%r" % self.__dict__
|
||||||
@ -87,13 +68,14 @@ class Program:
|
|||||||
class Season:
|
class Season:
|
||||||
""" Defines a Season. """
|
""" Defines a Season. """
|
||||||
|
|
||||||
def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, number=None):
|
def __init__(self, uuid=None, path=None, channel=None, title=None, description=None, cover=None, number=None):
|
||||||
"""
|
"""
|
||||||
:type uuid: str
|
:type uuid: str
|
||||||
:type path: str
|
:type path: str
|
||||||
:type channel: str
|
:type channel: str
|
||||||
:type title: str
|
:type title: str
|
||||||
:type description: str
|
:type description: str
|
||||||
|
:type cover: str
|
||||||
:type number: int
|
:type number: int
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@ -102,6 +84,7 @@ class Season:
|
|||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.title = title
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
|
self.cover = cover
|
||||||
self.number = number
|
self.number = number
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@ -111,8 +94,8 @@ class Season:
|
|||||||
class Episode:
|
class Episode:
|
||||||
""" Defines an Episode. """
|
""" Defines an Episode. """
|
||||||
|
|
||||||
def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, thumb=None, duration=None,
|
def __init__(self, uuid=None, nodeid=None, path=None, channel=None, program_title=None, title=None, description=None, cover=None, duration=None,
|
||||||
season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None, stream=None, content_type=None):
|
season=None, season_uuid=None, number=None, rating=None, aired=None, expiry=None):
|
||||||
"""
|
"""
|
||||||
:type uuid: str
|
:type uuid: str
|
||||||
:type nodeid: str
|
:type nodeid: str
|
||||||
@ -121,7 +104,7 @@ class Episode:
|
|||||||
:type program_title: str
|
:type program_title: str
|
||||||
:type title: str
|
:type title: str
|
||||||
:type description: str
|
:type description: str
|
||||||
:type thumb: str
|
:type cover: str
|
||||||
:type duration: int
|
:type duration: int
|
||||||
:type season: int
|
:type season: int
|
||||||
:type season_uuid: str
|
:type season_uuid: str
|
||||||
@ -129,8 +112,6 @@ class Episode:
|
|||||||
:type rating: str
|
:type rating: str
|
||||||
:type aired: datetime
|
:type aired: datetime
|
||||||
:type expiry: datetime
|
:type expiry: datetime
|
||||||
:type stream: string
|
|
||||||
:type content_type: string
|
|
||||||
"""
|
"""
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.nodeid = nodeid
|
self.nodeid = nodeid
|
||||||
@ -139,7 +120,7 @@ class Episode:
|
|||||||
self.program_title = program_title
|
self.program_title = program_title
|
||||||
self.title = title
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
self.thumb = thumb
|
self.cover = cover
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
self.season = season
|
self.season = season
|
||||||
self.season_uuid = season_uuid
|
self.season_uuid = season_uuid
|
||||||
@ -147,38 +128,19 @@ class Episode:
|
|||||||
self.rating = rating
|
self.rating = rating
|
||||||
self.aired = aired
|
self.aired = aired
|
||||||
self.expiry = expiry
|
self.expiry = expiry
|
||||||
self.stream = stream
|
|
||||||
self.content_type = content_type
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "%r" % self.__dict__
|
|
||||||
|
|
||||||
|
|
||||||
class Category:
|
|
||||||
""" Defines a Category. """
|
|
||||||
|
|
||||||
def __init__(self, uuid=None, channel=None, title=None, programs=None, episodes=None):
|
|
||||||
"""
|
|
||||||
:type uuid: str
|
|
||||||
:type channel: str
|
|
||||||
:type title: str
|
|
||||||
:type programs: List[Program]
|
|
||||||
:type episodes: List[Episode]
|
|
||||||
"""
|
|
||||||
self.uuid = uuid
|
|
||||||
self.channel = channel
|
|
||||||
self.title = title
|
|
||||||
self.programs = programs
|
|
||||||
self.episodes = episodes
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "%r" % self.__dict__
|
return "%r" % self.__dict__
|
||||||
|
|
||||||
|
|
||||||
class ContentApi:
|
class ContentApi:
|
||||||
""" GoPlay Content API"""
|
""" VIER/VIJF/ZES Content API"""
|
||||||
SITE_URL = 'https://www.goplay.be'
|
API_ENDPOINT = 'https://api.viervijfzes.be'
|
||||||
API_GOPLAY = 'https://api.goplay.be'
|
SITE_APIS = {
|
||||||
|
'vier': 'https://www.vier.be/api',
|
||||||
|
'vijf': 'https://www.vijf.be/api',
|
||||||
|
'zes': 'https://www.zestv.be/api',
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, auth=None, cache_path=None):
|
def __init__(self, auth=None, cache_path=None):
|
||||||
""" Initialise object """
|
""" Initialise object """
|
||||||
@ -186,482 +148,124 @@ class ContentApi:
|
|||||||
self._auth = auth
|
self._auth = auth
|
||||||
self._cache_path = cache_path
|
self._cache_path = cache_path
|
||||||
|
|
||||||
def get_programs(self, channel=None, cache=CACHE_AUTO):
|
def get_programs(self, channel, cache=CACHE_AUTO):
|
||||||
""" Get a list of all programs of the specified channel.
|
""" Get a list of all programs of the specified channel.
|
||||||
:type channel: str
|
:type channel: str
|
||||||
:type cache: str
|
:type cache: str
|
||||||
:rtype list[Program]
|
:rtype list[Program]
|
||||||
"""
|
"""
|
||||||
|
if channel not in CHANNELS:
|
||||||
|
raise Exception('Unknown channel %s' % channel)
|
||||||
|
|
||||||
def update():
|
def update():
|
||||||
""" Fetch the program listing by scraping """
|
""" Fetch the program listing by scraping """
|
||||||
# Load webpage
|
# Load webpage
|
||||||
raw_html = self._get_url(self.SITE_URL + '/programmas')
|
raw_html = self._get_url(CHANNELS[channel]['url'])
|
||||||
|
|
||||||
# Parse programs
|
# Parse programs
|
||||||
regex_programs = re.compile(r'data-program="(?P<json>[^"]+)"', re.DOTALL)
|
parser = HTMLParser()
|
||||||
|
regex_programs = re.compile(r'<a class="program-overview__link" href="(?P<path>[^"]+)">\s+'
|
||||||
data = [
|
r'<span class="program-overview__title">\s+(?P<title>[^<]+)</span>.*?'
|
||||||
json.loads(unescape(item.group('json')))
|
r'</a>', re.DOTALL)
|
||||||
|
data = {
|
||||||
|
item.group('path').lstrip('/'): parser.unescape(item.group('title').strip())
|
||||||
for item in regex_programs.finditer(raw_html)
|
for item in regex_programs.finditer(raw_html)
|
||||||
]
|
}
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
raise Exception('No programs found')
|
raise Exception('No programs found for %s' % channel)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# Fetch listing from cache or update if needed
|
# Fetch listing from cache or update if needed
|
||||||
data = self._handle_cache(key=['programs'], cache_mode=cache, update=update, ttl=30 * 60) # 30 minutes
|
data = self._handle_cache(key=['programs', channel], cache_mode=cache, update=update, ttl=30 * 5)
|
||||||
if not data:
|
if not data:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if channel:
|
programs = []
|
||||||
programs = [
|
for path in data:
|
||||||
self._parse_program_data(record) for record in data if record['pageInfo']['brand'] == channel
|
title = data[path]
|
||||||
]
|
program = self.get_program(channel, path, CACHE_ONLY) # Get program details, but from cache only
|
||||||
|
if program:
|
||||||
|
# Use program with metadata from cache
|
||||||
|
programs.append(program)
|
||||||
else:
|
else:
|
||||||
programs = [
|
# Use program with the values that we've parsed from the page
|
||||||
self._parse_program_data(record) for record in data
|
programs.append(Program(channel=channel,
|
||||||
]
|
path=path,
|
||||||
|
title=title))
|
||||||
return programs
|
return programs
|
||||||
|
|
||||||
def get_program(self, path, extract_clips=False, cache=CACHE_AUTO):
|
def get_program(self, channel, path, cache=CACHE_AUTO):
|
||||||
""" Get a Program object from the specified page.
|
""" Get a Program object from the specified page.
|
||||||
|
:type channel: str
|
||||||
:type path: str
|
:type path: str
|
||||||
:type extract_clips: bool
|
|
||||||
:type cache: int
|
:type cache: int
|
||||||
:rtype Program
|
:rtype Program
|
||||||
"""
|
"""
|
||||||
# We want to use the html to extract clips
|
if channel not in CHANNELS:
|
||||||
# This is the worst hack, since Python 2.7 doesn't support nonlocal
|
raise Exception('Unknown channel %s' % channel)
|
||||||
raw_html = [None]
|
|
||||||
|
|
||||||
def update():
|
def update():
|
||||||
""" Fetch the program metadata by scraping """
|
""" Fetch the program metadata by scraping """
|
||||||
# Fetch webpage
|
# Fetch webpage
|
||||||
page = self._get_url(self.SITE_URL + '/' + path)
|
page = self._get_url(CHANNELS[channel]['url'] + '/' + path)
|
||||||
|
|
||||||
# Store a copy in the parent's raw_html var.
|
|
||||||
raw_html[0] = page
|
|
||||||
|
|
||||||
# Extract JSON
|
# Extract JSON
|
||||||
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
|
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
|
||||||
json_data = unescape(regex_program.search(page).group(1))
|
json_data = HTMLParser().unescape(regex_program.search(page).group(1))
|
||||||
data = json.loads(json_data)['data']
|
data = json.loads(json_data)['data']
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# Fetch listing from cache or update if needed
|
# Fetch listing from cache or update if needed
|
||||||
data = self._handle_cache(key=['program', path], cache_mode=cache, update=update)
|
data = self._handle_cache(key=['program', channel, path], cache_mode=cache, update=update)
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
program = self._parse_program_data(data)
|
|
||||||
|
|
||||||
# Also extract clips if we did a real HTTP call
|
|
||||||
if extract_clips and raw_html[0]:
|
|
||||||
clips = self._extract_videos(raw_html[0])
|
|
||||||
program.clips = clips
|
|
||||||
|
|
||||||
return program
|
|
||||||
|
|
||||||
def get_program_by_uuid(self, uuid, cache=CACHE_AUTO):
|
|
||||||
""" Get a Program object with the specified uuid.
|
|
||||||
:type uuid: str
|
|
||||||
:type cache: str
|
|
||||||
:rtype Program
|
|
||||||
"""
|
|
||||||
if not uuid:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update():
|
|
||||||
""" Fetch the program metadata """
|
|
||||||
# Fetch webpage
|
|
||||||
result = self._get_url(self.SITE_URL + '/api/program/%s' % uuid)
|
|
||||||
data = json.loads(result)
|
|
||||||
return data
|
|
||||||
|
|
||||||
# Fetch listing from cache or update if needed
|
|
||||||
data = self._handle_cache(key=['program', uuid], cache_mode=cache, update=update)
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
program = self._parse_program_data(data)
|
program = self._parse_program_data(data)
|
||||||
|
|
||||||
return program
|
return program
|
||||||
|
|
||||||
def get_episode(self, path, cache=CACHE_AUTO):
|
def get_episode(self, channel, path):
|
||||||
""" Get a Episode object from the specified page.
|
""" Get a Episode object from the specified page.
|
||||||
|
:type channel: str
|
||||||
:type path: str
|
:type path: str
|
||||||
:type cache: str
|
|
||||||
:rtype Episode
|
:rtype Episode
|
||||||
|
NOTE: This function doesn't use an API.
|
||||||
"""
|
"""
|
||||||
|
if channel not in CHANNELS:
|
||||||
|
raise Exception('Unknown channel %s' % channel)
|
||||||
|
|
||||||
def update():
|
|
||||||
""" Fetch the program metadata by scraping """
|
|
||||||
# Load webpage
|
# Load webpage
|
||||||
page = self._get_url(self.SITE_URL + '/' + path)
|
page = self._get_url(CHANNELS[channel]['url'] + '/' + path)
|
||||||
|
|
||||||
program_json = None
|
|
||||||
episode_json = None
|
|
||||||
|
|
||||||
# Extract video JSON by looking for a data-video tag
|
|
||||||
# This is not present on every page
|
|
||||||
regex_video_data = re.compile(r'data-video="([^"]+)"', re.DOTALL)
|
|
||||||
result = regex_video_data.search(page)
|
|
||||||
if result:
|
|
||||||
video_id = json.loads(unescape(result.group(1)))['id']
|
|
||||||
video_json_data = self._get_url('%s/web/v1/videos/short-form/%s' % (self.API_GOPLAY, video_id))
|
|
||||||
video_json = json.loads(video_json_data)
|
|
||||||
return {'video': video_json}
|
|
||||||
|
|
||||||
# Extract program JSON
|
# Extract program JSON
|
||||||
|
parser = HTMLParser()
|
||||||
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
|
regex_program = re.compile(r'data-hero="([^"]+)', re.DOTALL)
|
||||||
result = regex_program.search(page)
|
json_data = parser.unescape(regex_program.search(page).group(1))
|
||||||
if result:
|
data = json.loads(json_data)['data']
|
||||||
program_json_data = unescape(result.group(1))
|
program = self._parse_program_data(data)
|
||||||
program_json = json.loads(program_json_data)['data']
|
|
||||||
|
|
||||||
# Extract episode JSON
|
# Extract episode JSON
|
||||||
regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', re.DOTALL)
|
regex_episode = re.compile(r'<script type="application/json" data-drupal-selector="drupal-settings-json">(.*?)</script>', re.DOTALL)
|
||||||
result = regex_episode.search(page)
|
json_data = parser.unescape(regex_episode.search(page).group(1))
|
||||||
if result:
|
data = json.loads(json_data)
|
||||||
episode_json_data = unescape(result.group(1))
|
|
||||||
episode_json = json.loads(episode_json_data)
|
|
||||||
|
|
||||||
return {'program': program_json, 'episode': episode_json}
|
# Lookup the episode in the program JSON based on the nodeId
|
||||||
|
# The episode we just found doesn't contain all information
|
||||||
# Fetch listing from cache or update if needed
|
|
||||||
data = self._handle_cache(key=['episode', path], cache_mode=cache, update=update)
|
|
||||||
if not data:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if 'episode' in data and data['episode']['pageInfo']['type'] == 'live_channel':
|
|
||||||
episode = Episode(
|
|
||||||
uuid=data['episode']['pageInfo']['nodeUuid'],
|
|
||||||
program_title=data['episode']['pageInfo']['title'],
|
|
||||||
content_type=data['episode']['pageInfo']['type'],
|
|
||||||
)
|
|
||||||
return episode
|
|
||||||
|
|
||||||
if 'video' in data and data['video']:
|
|
||||||
# We have found detailed episode information
|
|
||||||
episode = self._parse_clip_data(data['video'])
|
|
||||||
return episode
|
|
||||||
|
|
||||||
if 'program' in data and 'episode' in data and data['program'] and data['episode']:
|
|
||||||
# We don't have detailed episode information
|
|
||||||
# We need to lookup the episode in the program JSON
|
|
||||||
program = self._parse_program_data(data['program'])
|
|
||||||
for episode in program.episodes:
|
for episode in program.episodes:
|
||||||
if episode.nodeid == data['episode']['pageInfo']['nodeId']:
|
if episode.nodeid == data['pageInfo']['nodeId']:
|
||||||
return episode
|
return episode
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_stream_by_uuid(self, uuid, content_type):
|
def get_stream_by_uuid(self, uuid):
|
||||||
""" Return a ResolvedStream for this video.
|
""" Get the stream URL to use for this video.
|
||||||
:type uuid: string
|
:type uuid: str
|
||||||
:type content_type: string
|
:rtype str
|
||||||
:rtype: ResolvedStream
|
|
||||||
"""
|
"""
|
||||||
if content_type in ('video-long_form', 'long_form'):
|
response = self._get_url(self.API_ENDPOINT + '/content/%s' % uuid, authentication=True)
|
||||||
mode = 'videos/long-form'
|
|
||||||
elif content_type == 'video-short_form':
|
|
||||||
mode = 'videos/short-form'
|
|
||||||
elif content_type == 'live_channel':
|
|
||||||
mode = 'liveStreams'
|
|
||||||
response = self._get_url(self.API_GOPLAY + '/web/v1/%s/%s' % (mode, uuid), authentication='Bearer %s' % self._auth.get_token())
|
|
||||||
data = json.loads(response)
|
data = json.loads(response)
|
||||||
|
return data['video']['S']
|
||||||
if not data:
|
|
||||||
raise UnavailableException
|
|
||||||
|
|
||||||
# Get DRM license
|
|
||||||
license_key = None
|
|
||||||
if data.get('drmXml'):
|
|
||||||
# BuyDRM format
|
|
||||||
# See https://docs.unified-streaming.com/documentation/drm/buydrm.html#setting-up-the-client
|
|
||||||
|
|
||||||
# Generate license key
|
|
||||||
license_key = self.create_license_key('https://wv-keyos.licensekeyserver.com/', key_headers={
|
|
||||||
'customdata': data['drmXml']
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get manifest url
|
|
||||||
if data.get('manifestUrls'):
|
|
||||||
|
|
||||||
if data.get('manifestUrls').get('dash'):
|
|
||||||
# DASH stream
|
|
||||||
return ResolvedStream(
|
|
||||||
uuid=uuid,
|
|
||||||
url=data['manifestUrls']['dash'],
|
|
||||||
stream_type=STREAM_DASH,
|
|
||||||
license_key=license_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
# HLS stream
|
|
||||||
return ResolvedStream(
|
|
||||||
uuid=uuid,
|
|
||||||
url=data['manifestUrls']['hls'],
|
|
||||||
stream_type=STREAM_HLS,
|
|
||||||
license_key=license_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
# No manifest url found, get manifest from Server-Side Ad Insertion service
|
|
||||||
if data.get('adType') == 'SSAI' and data.get('ssai'):
|
|
||||||
url = 'https://pubads.g.doubleclick.net/ondemand/dash/content/%s/vid/%s/streams' % (
|
|
||||||
data.get('ssai').get('contentSourceID'), data.get('ssai').get('videoID'))
|
|
||||||
ad_data = json.loads(self._post_url(url, data=''))
|
|
||||||
|
|
||||||
# Server-Side Ad Insertion DASH stream
|
|
||||||
return ResolvedStream(
|
|
||||||
uuid=uuid,
|
|
||||||
url=ad_data['stream_manifest'],
|
|
||||||
stream_type=STREAM_DASH,
|
|
||||||
license_key=license_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
raise UnavailableException
|
|
||||||
|
|
||||||
def get_program_tree(self, cache=CACHE_AUTO):
|
|
||||||
""" Get a content tree with information about all the programs.
|
|
||||||
:type cache: str
|
|
||||||
:rtype dict
|
|
||||||
"""
|
|
||||||
|
|
||||||
def update():
|
|
||||||
""" Fetch the content tree """
|
|
||||||
response = self._get_url(self.SITE_URL + '/api/content_tree')
|
|
||||||
return json.loads(response)
|
|
||||||
|
|
||||||
# Fetch listing from cache or update if needed
|
|
||||||
data = self._handle_cache(key=['content_tree'], cache_mode=cache, update=update, ttl=5 * 60) # 5 minutes
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_popular_programs(self, brand=None):
|
|
||||||
""" Get a list of popular programs.
|
|
||||||
:rtype list[Program]
|
|
||||||
"""
|
|
||||||
if brand:
|
|
||||||
response = self._get_url(self.SITE_URL + '/api/programs/popular/%s' % brand)
|
|
||||||
else:
|
|
||||||
response = self._get_url(self.SITE_URL + '/api/programs/popular')
|
|
||||||
data = json.loads(response)
|
|
||||||
|
|
||||||
programs = []
|
|
||||||
for program in data:
|
|
||||||
programs.append(self._parse_program_data(program))
|
|
||||||
|
|
||||||
return programs
|
|
||||||
|
|
||||||
def get_categories(self):
|
|
||||||
""" Return a list of categories.
|
|
||||||
:rtype list[Category]
|
|
||||||
"""
|
|
||||||
content_tree = self.get_program_tree()
|
|
||||||
|
|
||||||
categories = []
|
|
||||||
for category_id, category_name in content_tree.get('categories').items():
|
|
||||||
categories.append(Category(uuid=category_id,
|
|
||||||
title=category_name))
|
|
||||||
|
|
||||||
return categories
|
|
||||||
|
|
||||||
def get_category_content(self, category_id):
|
|
||||||
""" Return a category.
|
|
||||||
:type category_id: int
|
|
||||||
:rtype list[Program]
|
|
||||||
"""
|
|
||||||
content_tree = self.get_program_tree()
|
|
||||||
|
|
||||||
# Find out all the program_id's of the requested category
|
|
||||||
program_ids = [key for key, value in content_tree.get('programs').items() if value.get('category') == category_id]
|
|
||||||
|
|
||||||
# Filter out the list of all programs to only keep the one of the requested category
|
|
||||||
return [program for program in self.get_programs() if program.uuid in program_ids]
|
|
||||||
|
|
||||||
def get_recommendation_categories(self):
|
|
||||||
""" Get a list of all categories.
|
|
||||||
:rtype list[Category]
|
|
||||||
"""
|
|
||||||
# Load all programs
|
|
||||||
all_programs = self.get_programs()
|
|
||||||
|
|
||||||
# Load webpage
|
|
||||||
raw_html = self._get_url(self.SITE_URL)
|
|
||||||
|
|
||||||
# Categories regexes
|
|
||||||
regex_articles = re.compile(r'<article[^>]+>([\s\S]*?)</article>', re.DOTALL)
|
|
||||||
regex_category = re.compile(r'<h2.*?>(.*?)</h2>(?:.*?<div class=\"visually-hidden\">(.*?)</div>)?', re.DOTALL)
|
|
||||||
|
|
||||||
categories = []
|
|
||||||
for result in regex_articles.finditer(raw_html):
|
|
||||||
article_html = result.group(1)
|
|
||||||
|
|
||||||
match_category = regex_category.search(article_html)
|
|
||||||
category_title = None
|
|
||||||
if match_category:
|
|
||||||
category_title = unescape(match_category.group(1).strip())
|
|
||||||
if match_category.group(2):
|
|
||||||
category_title += ' [B]%s[/B]' % unescape(match_category.group(2).strip())
|
|
||||||
|
|
||||||
if category_title:
|
|
||||||
# Extract programs and lookup in all_programs so we have more metadata
|
|
||||||
programs = []
|
|
||||||
for program in self._extract_programs(article_html):
|
|
||||||
try:
|
|
||||||
rich_program = next(rich_program for rich_program in all_programs if rich_program.path == program.path)
|
|
||||||
programs.append(rich_program)
|
|
||||||
except StopIteration:
|
|
||||||
programs.append(program)
|
|
||||||
|
|
||||||
episodes = self._extract_videos(article_html)
|
|
||||||
|
|
||||||
categories.append(
|
|
||||||
Category(uuid=hashlib.md5(category_title.encode('utf-8')).hexdigest(), title=category_title, programs=programs, episodes=episodes))
|
|
||||||
|
|
||||||
return categories
|
|
||||||
|
|
||||||
def get_mylist(self):
|
|
||||||
""" Get the content of My List
|
|
||||||
:rtype list[Program]
|
|
||||||
"""
|
|
||||||
data = self._get_url(self.API_GOPLAY + '/my-list', authentication='Bearer %s' % self._auth.get_token())
|
|
||||||
result = json.loads(data)
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for item in result:
|
|
||||||
try:
|
|
||||||
program = self.get_program_by_uuid(item.get('programId'))
|
|
||||||
if program:
|
|
||||||
program.my_list = True
|
|
||||||
items.append(program)
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
_LOGGER.warning(exc)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
def mylist_add(self, program_id):
|
|
||||||
""" Add a program on My List """
|
|
||||||
self._post_url(self.API_GOPLAY + '/my-list', data={'programId': program_id}, authentication='Bearer %s' % self._auth.get_token())
|
|
||||||
|
|
||||||
def mylist_del(self, program_id):
|
|
||||||
""" Remove a program on My List """
|
|
||||||
self._delete_url(self.API_GOPLAY + '/my-list-item', params={'programId': program_id}, authentication='Bearer %s' % self._auth.get_token())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_programs(html):
|
|
||||||
""" Extract Programs from HTML code
|
|
||||||
:type html: str
|
|
||||||
:rtype list[Program]
|
|
||||||
"""
|
|
||||||
# Item regexes
|
|
||||||
regex_item = re.compile(r'<a[^>]+?href=\"(?P<path>[^\"]+)\"[^>]+?>'
|
|
||||||
r'[\s\S]*?<h3 class=\"poster-teaser__title\">(?P<title>[^<]*)</h3>[\s\S]*?poster-teaser__image\" src=\"(?P<image>[\s\S]*?)\"[\s\S]*?'
|
|
||||||
r'</a>', re.DOTALL)
|
|
||||||
|
|
||||||
# Extract items
|
|
||||||
programs = []
|
|
||||||
for item in regex_item.finditer(html):
|
|
||||||
path = item.group('path')
|
|
||||||
if path.startswith('/video'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Program
|
|
||||||
programs.append(Program(
|
|
||||||
path=path.lstrip('/'),
|
|
||||||
title=unescape(item.group('title')),
|
|
||||||
poster=unescape(item.group('image')),
|
|
||||||
))
|
|
||||||
|
|
||||||
return programs
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_videos(html):
|
|
||||||
""" Extract videos from HTML code
|
|
||||||
:type html: str
|
|
||||||
:rtype list[Episode]
|
|
||||||
"""
|
|
||||||
# Item regexes
|
|
||||||
regex_item = re.compile(r'<a[^>]+?class=\"(?P<item_type>[^\"]+)\"[^>]+?href=\"(?P<path>[^\"]+)\"[^>]+?>[\s\S]*?</a>', re.DOTALL)
|
|
||||||
|
|
||||||
regex_episode_program = re.compile(r'<(?:div|h3) class=\"episode-teaser__subtitle\">([^<]*)</(?:div|h3)>')
|
|
||||||
regex_episode_title = re.compile(r'<(?:div|h3) class=\"(?:poster|card|image|episode)-teaser__title\">(?:<span>)?([^<]*)(?:</span>)?</(?:div|h3)>')
|
|
||||||
regex_episode_duration = re.compile(r'data-duration=\"([^\"]*)\"')
|
|
||||||
regex_episode_video_id = re.compile(r'data-video-id=\"([^\"]*)\"')
|
|
||||||
regex_episode_image = re.compile(r'<img class=\"episode-teaser__header\" src=\"([^<\"]*)\"')
|
|
||||||
regex_episode_badge = re.compile(r'<div class=\"badge (?:poster|card|image|episode)-teaser__badge (?:poster|card|image|episode)-teaser__badge--default\">([^<]*)</div>')
|
|
||||||
|
|
||||||
# Extract items
|
|
||||||
episodes = []
|
|
||||||
for item in regex_item.finditer(html):
|
|
||||||
item_html = item.group(0)
|
|
||||||
path = item.group('path')
|
|
||||||
item_type = item.group('item_type')
|
|
||||||
|
|
||||||
# Extract title
|
|
||||||
try:
|
|
||||||
title = unescape(regex_episode_title.search(item_html).group(1))
|
|
||||||
except AttributeError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# This is not a video
|
|
||||||
if not path.startswith('/video'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
episode_program = regex_episode_program.search(item_html).group(1)
|
|
||||||
except AttributeError:
|
|
||||||
_LOGGER.warning('Found no episode_program for %s', title)
|
|
||||||
episode_program = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
episode_duration = int(regex_episode_duration.search(item_html).group(1))
|
|
||||||
except AttributeError:
|
|
||||||
_LOGGER.warning('Found no episode_duration for %s', title)
|
|
||||||
episode_duration = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
episode_video_id = regex_episode_video_id.search(item_html).group(1)
|
|
||||||
except AttributeError:
|
|
||||||
_LOGGER.warning('Found no episode_video_id for %s', title)
|
|
||||||
episode_video_id = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
episode_image = unescape(regex_episode_image.search(item_html).group(1))
|
|
||||||
except AttributeError:
|
|
||||||
_LOGGER.warning('Found no episode_image for %s', title)
|
|
||||||
episode_image = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
episode_badge = unescape(regex_episode_badge.search(item_html).group(1))
|
|
||||||
except AttributeError:
|
|
||||||
episode_badge = None
|
|
||||||
|
|
||||||
description = title
|
|
||||||
if episode_badge:
|
|
||||||
description += "\n\n[B]%s[/B]" % episode_badge
|
|
||||||
|
|
||||||
content_type = 'video-short_form' if 'card-' in item_type else 'video-long_form'
|
|
||||||
|
|
||||||
# Episode
|
|
||||||
episodes.append(Episode(
|
|
||||||
path=path.lstrip('/'),
|
|
||||||
channel='', # TODO
|
|
||||||
title=title,
|
|
||||||
description=html_to_kodi(description),
|
|
||||||
duration=episode_duration,
|
|
||||||
uuid=episode_video_id,
|
|
||||||
thumb=episode_image,
|
|
||||||
program_title=episode_program,
|
|
||||||
content_type=content_type
|
|
||||||
))
|
|
||||||
|
|
||||||
return episodes
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_program_data(data):
|
def _parse_program_data(data):
|
||||||
@ -669,48 +273,51 @@ class ContentApi:
|
|||||||
:type data: dict
|
:type data: dict
|
||||||
:rtype Program
|
:rtype Program
|
||||||
"""
|
"""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
# Create Program info
|
# Create Program info
|
||||||
program = Program(
|
program = Program(
|
||||||
uuid=data.get('id'),
|
uuid=data['id'],
|
||||||
path=data.get('link').lstrip('/'),
|
path=data['link'].lstrip('/'),
|
||||||
channel=data.get('pageInfo').get('brand'),
|
channel=data['pageInfo']['site'],
|
||||||
title=data.get('title'),
|
title=data['title'],
|
||||||
description=html_to_kodi(data.get('description')),
|
description=data['description'],
|
||||||
aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate', 0.0)),
|
aired=datetime.fromtimestamp(data.get('pageInfo', {}).get('publishDate')),
|
||||||
poster=data.get('images').get('poster'),
|
cover=data['images']['poster'],
|
||||||
thumb=data.get('images').get('teaser'),
|
background=data['images']['hero'],
|
||||||
fanart=data.get('images').get('teaser'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Season info
|
# Create Season info
|
||||||
program.seasons = {
|
program.seasons = {
|
||||||
key: Season(
|
key: Season(
|
||||||
uuid=playlist.get('id'),
|
uuid=playlist['id'],
|
||||||
path=playlist.get('link').lstrip('/'),
|
path=playlist['link'].lstrip('/'),
|
||||||
channel=playlist.get('pageInfo').get('brand'),
|
channel=playlist['pageInfo']['site'],
|
||||||
title=playlist.get('title'),
|
title=playlist['title'],
|
||||||
description=html_to_kodi(playlist.get('description')),
|
description=playlist['pageInfo']['description'],
|
||||||
number=playlist.get('episodes')[0].get('seasonNumber'), # You did not see this
|
number=playlist['episodes'][0]['seasonNumber'], # You did not see this
|
||||||
)
|
)
|
||||||
for key, playlist in enumerate(data.get('playlists', [])) if playlist.get('episodes')
|
for key, playlist in enumerate(data['playlists']) if playlist['episodes']
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create Episodes info
|
# Create Episodes info
|
||||||
program.episodes = [
|
program.episodes = [
|
||||||
ContentApi._parse_episode_data(episode, playlist.get('id'))
|
ContentApi._parse_episode_data(episode, playlist['id'])
|
||||||
for playlist in data.get('playlists', [])
|
for playlist in data['playlists']
|
||||||
for episode in playlist.get('episodes')
|
for episode in playlist['episodes']
|
||||||
]
|
]
|
||||||
|
|
||||||
return program
|
return program
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_episode_data(data, season_uuid=None):
|
def _parse_episode_data(data, season_uuid):
|
||||||
""" Parse the Episode JSON.
|
""" Parse the Episode JSON.
|
||||||
:type data: dict
|
:type data: dict
|
||||||
:type season_uuid: str
|
:type season_uuid: str
|
||||||
:rtype Episode
|
:rtype Episode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if data.get('episodeNumber'):
|
if data.get('episodeNumber'):
|
||||||
episode_number = data.get('episodeNumber')
|
episode_number = data.get('episodeNumber')
|
||||||
else:
|
else:
|
||||||
@ -726,113 +333,33 @@ class ContentApi:
|
|||||||
nodeid=data.get('pageInfo', {}).get('nodeId'),
|
nodeid=data.get('pageInfo', {}).get('nodeId'),
|
||||||
path=data.get('link').lstrip('/'),
|
path=data.get('link').lstrip('/'),
|
||||||
channel=data.get('pageInfo', {}).get('site'),
|
channel=data.get('pageInfo', {}).get('site'),
|
||||||
program_title=data.get('program', {}).get('title') if data.get('program') else data.get('title'),
|
program_title=data.get('program', {}).get('title'),
|
||||||
title=data.get('title'),
|
title=data.get('title'),
|
||||||
description=html_to_kodi(data.get('description')),
|
description=data.get('pageInfo', {}).get('description'),
|
||||||
thumb=data.get('image'),
|
cover=data.get('image'),
|
||||||
duration=data.get('duration'),
|
duration=data.get('duration'),
|
||||||
season=data.get('seasonNumber'),
|
season=data.get('seasonNumber'),
|
||||||
season_uuid=season_uuid,
|
season_uuid=season_uuid,
|
||||||
number=episode_number,
|
number=episode_number,
|
||||||
aired=datetime.fromtimestamp(int(data.get('createdDate'))),
|
aired=datetime.fromtimestamp(data.get('createdDate')),
|
||||||
expiry=datetime.fromtimestamp(int(data.get('unpublishDate'))) if data.get('unpublishDate') else None,
|
expiry=datetime.fromtimestamp(data.get('unpublishDate')) if data.get('unpublishDate') else None,
|
||||||
rating=data.get('parentalRating'),
|
rating=data.get('parentalRating')
|
||||||
stream=data.get('path'),
|
|
||||||
content_type=data.get('type'),
|
|
||||||
)
|
)
|
||||||
return episode
|
return episode
|
||||||
|
|
||||||
@staticmethod
|
def _get_url(self, url, params=None, authentication=False):
|
||||||
def _parse_clip_data(data):
|
|
||||||
""" Parse the Clip JSON.
|
|
||||||
:type data: dict
|
|
||||||
:rtype Episode
|
|
||||||
"""
|
|
||||||
episode = Episode(
|
|
||||||
uuid=data.get('videoUuid'),
|
|
||||||
program_title=data.get('title'),
|
|
||||||
title=data.get('title'),
|
|
||||||
)
|
|
||||||
return episode
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_license_key(key_url, key_type='R', key_headers=None, key_value='', response_value=''):
|
|
||||||
""" Create a license key string that we need for inputstream.adaptive.
|
|
||||||
:type key_url: str
|
|
||||||
:type key_type: str
|
|
||||||
:type key_headers: dict[str, str]
|
|
||||||
:type key_value: str
|
|
||||||
:type response_value: str
|
|
||||||
:rtype str
|
|
||||||
"""
|
|
||||||
try: # Python 3
|
|
||||||
from urllib.parse import quote, urlencode
|
|
||||||
except ImportError: # Python 2
|
|
||||||
from urllib import quote, urlencode
|
|
||||||
|
|
||||||
header = ''
|
|
||||||
if key_headers:
|
|
||||||
header = urlencode(key_headers)
|
|
||||||
|
|
||||||
if key_type in ('A', 'R', 'B'):
|
|
||||||
key_value = key_type + '{SSM}'
|
|
||||||
elif key_type == 'D':
|
|
||||||
if 'D{SSM}' not in key_value:
|
|
||||||
raise ValueError('Missing D{SSM} placeholder')
|
|
||||||
key_value = quote(key_value)
|
|
||||||
|
|
||||||
return '%s|%s|%s|%s' % (key_url, header, key_value, response_value)
|
|
||||||
|
|
||||||
def _get_url(self, url, params=None, authentication=None):
|
|
||||||
""" Makes a GET request for the specified URL.
|
""" Makes a GET request for the specified URL.
|
||||||
:type url: str
|
:type url: str
|
||||||
:type authentication: str
|
|
||||||
:rtype str
|
:rtype str
|
||||||
"""
|
"""
|
||||||
if authentication:
|
if authentication:
|
||||||
|
if not self._auth:
|
||||||
|
raise Exception('Requested to authenticate, but not auth object passed')
|
||||||
response = self._session.get(url, params=params, headers={
|
response = self._session.get(url, params=params, headers={
|
||||||
'authorization': authentication,
|
'authorization': self._auth.get_token(),
|
||||||
}, proxies=PROXIES)
|
})
|
||||||
else:
|
else:
|
||||||
response = self._session.get(url, params=params, proxies=PROXIES)
|
response = self._session.get(url, params=params)
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
_LOGGER.error(response.text)
|
|
||||||
raise Exception('Could not fetch data')
|
|
||||||
|
|
||||||
return response.text
|
|
||||||
|
|
||||||
def _post_url(self, url, params=None, data=None, authentication=None):
|
|
||||||
""" Makes a POST request for the specified URL.
|
|
||||||
:type url: str
|
|
||||||
:type authentication: str
|
|
||||||
:rtype str
|
|
||||||
"""
|
|
||||||
if authentication:
|
|
||||||
response = self._session.post(url, params=params, json=data, headers={
|
|
||||||
'authorization': authentication,
|
|
||||||
}, proxies=PROXIES)
|
|
||||||
else:
|
|
||||||
response = self._session.post(url, params=params, json=data, proxies=PROXIES)
|
|
||||||
|
|
||||||
if response.status_code not in (200, 201):
|
|
||||||
_LOGGER.error(response.text)
|
|
||||||
raise Exception('Could not fetch data')
|
|
||||||
|
|
||||||
return response.text
|
|
||||||
|
|
||||||
def _delete_url(self, url, params=None, authentication=None):
|
|
||||||
""" Makes a DELETE request for the specified URL.
|
|
||||||
:type url: str
|
|
||||||
:type authentication: str
|
|
||||||
:rtype str
|
|
||||||
"""
|
|
||||||
if authentication:
|
|
||||||
response = self._session.delete(url, params=params, headers={
|
|
||||||
'authorization': authentication,
|
|
||||||
}, proxies=PROXIES)
|
|
||||||
else:
|
|
||||||
response = self._session.delete(url, params=params, proxies=PROXIES)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
_LOGGER.error(response.text)
|
_LOGGER.error(response.text)
|
||||||
@ -866,8 +393,8 @@ class ContentApi:
|
|||||||
|
|
||||||
def _get_cache(self, key, allow_expired=False):
|
def _get_cache(self, key, allow_expired=False):
|
||||||
""" Get an item from the cache """
|
""" Get an item from the cache """
|
||||||
filename = ('.'.join(key) + '.json').replace('/', '_')
|
filename = '.'.join(key) + '.json'
|
||||||
fullpath = os.path.join(self._cache_path, filename)
|
fullpath = self._cache_path + filename
|
||||||
|
|
||||||
if not os.path.exists(fullpath):
|
if not os.path.exists(fullpath):
|
||||||
return None
|
return None
|
||||||
@ -885,11 +412,11 @@ class ContentApi:
|
|||||||
|
|
||||||
def _set_cache(self, key, data, ttl):
|
def _set_cache(self, key, data, ttl):
|
||||||
""" Store an item in the cache """
|
""" Store an item in the cache """
|
||||||
filename = ('.'.join(key) + '.json').replace('/', '_')
|
filename = '.'.join(key) + '.json'
|
||||||
fullpath = os.path.join(self._cache_path, filename)
|
fullpath = self._cache_path + filename
|
||||||
|
|
||||||
if not os.path.exists(self._cache_path):
|
if not os.path.exists(self._cache_path):
|
||||||
os.makedirs(self._cache_path)
|
os.mkdir(self._cache_path)
|
||||||
|
|
||||||
with open(fullpath, 'w') as fdesc:
|
with open(fullpath, 'w') as fdesc:
|
||||||
_LOGGER.debug('Storing to cache as %s', filename)
|
_LOGGER.debug('Storing to cache as %s', filename)
|
||||||
|
@ -7,42 +7,18 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import dateutil.parser
|
import dateutil
|
||||||
import dateutil.tz
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
_LOGGER = logging.getLogger('epg-api')
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
GENRE_MAPPING = {
|
|
||||||
'Detective': 0x11,
|
|
||||||
'Dramaserie': 0x15,
|
|
||||||
'Fantasy': 0x13,
|
|
||||||
'Human Interest': 0x00,
|
|
||||||
'Informatief': 0x20,
|
|
||||||
'Komedie': 0x14,
|
|
||||||
'Komische serie': 0x14,
|
|
||||||
'Kookprogramma': '',
|
|
||||||
'Misdaadserie': 0x15,
|
|
||||||
'Politieserie': 0x17,
|
|
||||||
'Reality': 0x31,
|
|
||||||
'Science Fiction': 0x13,
|
|
||||||
'Show': 0x30,
|
|
||||||
'Thriller': 0x11,
|
|
||||||
'Voetbal': 0x43,
|
|
||||||
}
|
|
||||||
|
|
||||||
PROXIES = kodiutils.get_proxies()
|
|
||||||
|
|
||||||
|
|
||||||
class EpgProgram:
|
class EpgProgram:
|
||||||
""" Defines a Program in the EPG. """
|
""" Defines a Program in the EPG. """
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
def __init__(self, channel, program_title, episode_title, episode_title_original, number, season, genre, start,
|
def __init__(self, channel, program_title, episode_title, episode_title_original, number, season, genre, start, won_id, won_program_id, program_description,
|
||||||
won_id, won_program_id, program_description, description, duration, program_url, video_url, thumb,
|
description, duration, program_url, video_url, cover, airing):
|
||||||
airing):
|
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.program_title = program_title
|
self.program_title = program_title
|
||||||
self.episode_title = episode_title
|
self.episode_title = episode_title
|
||||||
@ -58,31 +34,22 @@ class EpgProgram:
|
|||||||
self.duration = duration
|
self.duration = duration
|
||||||
self.program_url = program_url
|
self.program_url = program_url
|
||||||
self.video_url = video_url
|
self.video_url = video_url
|
||||||
self.thumb = thumb
|
self.cover = cover
|
||||||
self.airing = airing
|
self.airing = airing
|
||||||
|
|
||||||
if GENRE_MAPPING.get(self.genre):
|
|
||||||
self.genre_id = GENRE_MAPPING.get(self.genre)
|
|
||||||
else:
|
|
||||||
self.genre_id = None
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "%r" % self.__dict__
|
return "%r" % self.__dict__
|
||||||
|
|
||||||
|
|
||||||
class EpgApi:
|
class EpgApi:
|
||||||
""" GoPlay EPG API """
|
""" VIER/VIJF/ZES EPG API """
|
||||||
|
|
||||||
EPG_ENDPOINTS = {
|
EPG_ENDPOINTS = {
|
||||||
'Play4': 'https://www.goplay.be/api/epg/vier/{date}',
|
'vier': 'https://www.vier.be/api/epg/{date}',
|
||||||
'Play5': 'https://www.goplay.be/api/epg/vijf/{date}',
|
'vijf': 'https://www.vijf.be/api/epg/{date}',
|
||||||
'Play6': 'https://www.goplay.be/api/epg/zes/{date}',
|
'zes': 'https://www.zestv.be/api/epg/{date}',
|
||||||
'Play7': 'https://www.goplay.be/api/epg/zeven/{date}',
|
|
||||||
'PlayCrime': 'https://www.goplay.be/api/epg/crime/{date}',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
EPG_NO_BROADCAST = 'Geen uitzending'
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
""" Initialise object """
|
""" Initialise object """
|
||||||
self._session = requests.session()
|
self._session = requests.session()
|
||||||
@ -111,7 +78,7 @@ class EpgApi:
|
|||||||
data = json.loads(response)
|
data = json.loads(response)
|
||||||
|
|
||||||
# Parse the results
|
# Parse the results
|
||||||
return [self._parse_program(channel, x) for x in data if x.get('program_title') != self.EPG_NO_BROADCAST]
|
return [self._parse_program(channel, x) for x in data]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_program(channel, data):
|
def _parse_program(channel, data):
|
||||||
@ -123,8 +90,8 @@ class EpgApi:
|
|||||||
duration = int(data.get('duration')) if data.get('duration') else None
|
duration = int(data.get('duration')) if data.get('duration') else None
|
||||||
|
|
||||||
# Check if this broadcast is currently airing
|
# Check if this broadcast is currently airing
|
||||||
timestamp = datetime.now().replace(tzinfo=dateutil.tz.gettz('CET'))
|
timestamp = datetime.now()
|
||||||
start = datetime.fromtimestamp(data.get('timestamp')).replace(tzinfo=dateutil.tz.gettz('CET'))
|
start = datetime.fromtimestamp(data.get('timestamp'))
|
||||||
if duration:
|
if duration:
|
||||||
airing = bool(start <= timestamp < (start + timedelta(seconds=duration)))
|
airing = bool(start <= timestamp < (start + timedelta(seconds=duration)))
|
||||||
else:
|
else:
|
||||||
@ -133,10 +100,10 @@ class EpgApi:
|
|||||||
# Only allow direct playing if the linked video is the actual program
|
# Only allow direct playing if the linked video is the actual program
|
||||||
if data.get('video_node', {}).get('latest_video'):
|
if data.get('video_node', {}).get('latest_video'):
|
||||||
video_url = (data.get('video_node', {}).get('url') or '').lstrip('/')
|
video_url = (data.get('video_node', {}).get('url') or '').lstrip('/')
|
||||||
thumb = data.get('video_node', {}).get('image')
|
cover = data.get('video_node', {}).get('image')
|
||||||
else:
|
else:
|
||||||
video_url = None
|
video_url = None
|
||||||
thumb = None
|
cover = None
|
||||||
|
|
||||||
return EpgProgram(
|
return EpgProgram(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@ -144,7 +111,7 @@ class EpgApi:
|
|||||||
episode_title=data.get('episode_title'),
|
episode_title=data.get('episode_title'),
|
||||||
episode_title_original=data.get('original_title'),
|
episode_title_original=data.get('original_title'),
|
||||||
number=int(data.get('episode_nr')) if data.get('episode_nr') else None,
|
number=int(data.get('episode_nr')) if data.get('episode_nr') else None,
|
||||||
season=data.get('season'),
|
season=int(data.get('season')) if data.get('season') else None,
|
||||||
genre=data.get('genre'),
|
genre=data.get('genre'),
|
||||||
start=start,
|
start=start,
|
||||||
won_id=int(data.get('won_id')) if data.get('won_id') else None,
|
won_id=int(data.get('won_id')) if data.get('won_id') else None,
|
||||||
@ -154,7 +121,7 @@ class EpgApi:
|
|||||||
duration=duration,
|
duration=duration,
|
||||||
program_url=(data.get('program_node', {}).get('url') or '').lstrip('/'),
|
program_url=(data.get('program_node', {}).get('url') or '').lstrip('/'),
|
||||||
video_url=video_url,
|
video_url=video_url,
|
||||||
thumb=thumb,
|
cover=cover,
|
||||||
airing=airing,
|
airing=airing,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -165,14 +132,14 @@ class EpgApi:
|
|||||||
:rtype: EpgProgram
|
:rtype: EpgProgram
|
||||||
"""
|
"""
|
||||||
# Parse to a real datetime
|
# Parse to a real datetime
|
||||||
timestamp = dateutil.parser.parse(timestamp).replace(tzinfo=dateutil.tz.gettz('CET'))
|
timestamp = dateutil.parser.parse(timestamp)
|
||||||
|
|
||||||
# Load guide info for this date
|
# Load guide info for this date
|
||||||
programs = self.get_epg(channel=channel, date=timestamp.strftime('%Y-%m-%d'))
|
programs = self.get_epg(channel=channel, date=timestamp.strftime('%Y-%m-%d'))
|
||||||
|
|
||||||
# Find a matching broadcast
|
# Find a matching broadcast
|
||||||
for broadcast in programs:
|
for broadcast in programs:
|
||||||
if broadcast.start <= timestamp < (broadcast.start + timedelta(seconds=broadcast.duration)):
|
if timestamp <= broadcast.start < (broadcast.start + timedelta(seconds=broadcast.duration)):
|
||||||
return broadcast
|
return broadcast
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@ -182,7 +149,7 @@ class EpgApi:
|
|||||||
:type url: str
|
:type url: str
|
||||||
:rtype str
|
:rtype str
|
||||||
"""
|
"""
|
||||||
response = self._session.get(url, proxies=PROXIES)
|
response = self._session.get(url)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise Exception('Could not fetch data')
|
raise Exception('Could not fetch data')
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
""" Search API """
|
""" AUTH API """
|
||||||
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
@ -8,21 +8,17 @@ import logging
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
from resources.lib.viervijfzes.content import Program
|
||||||
from resources.lib.viervijfzes.content import CACHE_ONLY, ContentApi, Program
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('search-api')
|
||||||
|
|
||||||
PROXIES = kodiutils.get_proxies()
|
|
||||||
|
|
||||||
|
|
||||||
class SearchApi:
|
class SearchApi:
|
||||||
""" GoPlay Search API """
|
""" VIER/VIJF/ZES Search API """
|
||||||
API_ENDPOINT = 'https://api.goplay.be/search'
|
API_ENDPOINT = 'https://api.viervijfzes.be/search'
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
""" Initialise object """
|
""" Initialise object """
|
||||||
self._api = ContentApi(None, cache_path=kodiutils.get_cache_path())
|
|
||||||
self._session = requests.session()
|
self._session = requests.session()
|
||||||
|
|
||||||
def search(self, query):
|
def search(self, query):
|
||||||
@ -37,28 +33,26 @@ class SearchApi:
|
|||||||
self.API_ENDPOINT,
|
self.API_ENDPOINT,
|
||||||
json={
|
json={
|
||||||
"query": query,
|
"query": query,
|
||||||
|
"sites": ["vier", "vijf", "zes"],
|
||||||
"page": 0,
|
"page": 0,
|
||||||
"mode": "programs"
|
"mode": "byDate"
|
||||||
},
|
}
|
||||||
proxies=PROXIES
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception('Could not search')
|
||||||
|
|
||||||
data = json.loads(response.text)
|
data = json.loads(response.text)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for hit in data['hits']['hits']:
|
for hit in data['hits']['hits']:
|
||||||
if hit['_source']['bundle'] == 'program':
|
if hit['_source']['bundle'] == 'program':
|
||||||
path = hit['_source']['url'].split('/')[-1]
|
|
||||||
program = self._api.get_program(path, cache=CACHE_ONLY)
|
|
||||||
if program:
|
|
||||||
results.append(program)
|
|
||||||
else:
|
|
||||||
results.append(Program(
|
results.append(Program(
|
||||||
path=path,
|
channel=hit['_source']['site'],
|
||||||
|
path=hit['_source']['url'].strip('/'),
|
||||||
title=hit['_source']['title'],
|
title=hit['_source']['title'],
|
||||||
description=hit['_source']['intro'],
|
description=hit['_source']['intro'],
|
||||||
poster=hit['_source']['img'],
|
cover=hit['_source']['img'],
|
||||||
))
|
))
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 8.2 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 12 KiB |
BIN
resources/logos/vier-background.jpg
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
resources/logos/vier.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
resources/logos/vijf-background.jpg
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
resources/logos/vijf.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
resources/logos/zes-background.jpg
Normal file
After Width: | Height: | Size: 59 KiB |
BIN
resources/logos/zes.png
Normal file
After Width: | Height: | Size: 9.9 KiB |
@ -1,26 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||||
<settings>
|
<settings>
|
||||||
|
<setting id="metadata_last_updated" visible="false"/>
|
||||||
<category label="30800"> <!-- Credentials -->
|
<category label="30800"> <!-- Credentials -->
|
||||||
<setting label="30801" type="lsep"/> <!-- Credentials -->
|
<setting label="30801" type="lsep"/> <!-- Credentials -->
|
||||||
<setting label="30802" type="text" id="username"/>
|
<setting label="30803" type="text" id="username"/>
|
||||||
<setting label="30803" type="text" id="password" option="hidden"/>
|
<setting label="30805" type="text" id="password" option="hidden"/>
|
||||||
</category>
|
</category>
|
||||||
<category label="30820"> <!-- Interface -->
|
<category label="30820"> <!-- Interface -->
|
||||||
<setting label="30820" type="lsep"/> <!-- Interface -->
|
<setting label="30827" type="lsep"/> <!-- Metadata -->
|
||||||
<setting label="30821" type="bool" id="interface_show_unavailable" default="true"/>
|
<setting label="30829" type="bool" id="metadata_update" default="true" subsetting="true"/>
|
||||||
|
<setting label="30831" type="action" action="RunPlugin(plugin://plugin.video.viervijfzes/metadata/update)"/>
|
||||||
</category>
|
</category>
|
||||||
<category label="30840"> <!-- Integrations -->
|
<category label="30840"> <!-- Playback from cache -->
|
||||||
<setting label="30841" type="lsep"/> <!-- IPTV Manager -->
|
<setting label="30841" type="bool" id="episode_cache_enabled" default="true"/>
|
||||||
<setting label="30842" type="action" action="InstallAddon(service.iptv.manager)" option="close" visible="!System.HasAddon(service.iptv.manager)"/> <!-- Install IPTV Manager add-on -->
|
<setting label="30843" type="folder" id="episode_cache_folder" source="local" option="writeable" enable="eq(-1,true)"/>
|
||||||
<setting label="30843" type="bool" id="iptv.enabled" default="true" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(service.iptv.manager) | System.AddonIsEnabled(service.iptv.manager)" />
|
|
||||||
<setting label="30844" type="action" action="Addon.OpenSettings(service.iptv.manager)" enable="eq(-1,true)" option="close" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(service.iptv.manager) | System.AddonIsEnabled(service.iptv.manager)" subsetting="true"/> <!-- IPTV Manager settings -->
|
|
||||||
<setting id="iptv.channels_uri" default="plugin://plugin.video.viervijfzes/iptv/channels" visible="false"/>
|
|
||||||
<setting id="iptv.epg_uri" default="plugin://plugin.video.viervijfzes/iptv/epg" visible="false"/>
|
|
||||||
</category>
|
|
||||||
<category label="30880"> <!-- Expert -->
|
|
||||||
<setting label="30881" type="lsep"/> <!-- Logging -->
|
|
||||||
<setting label="30882" type="bool" id="debug_logging" default="false"/>
|
|
||||||
<setting label="30883" type="action" action="InstallAddon(script.kodi.loguploader)" option="close" visible="!System.HasAddon(script.kodi.loguploader)"/> <!-- Install Kodi Logfile Uploader -->
|
|
||||||
<setting label="30884" type="action" action="RunAddon(script.kodi.loguploader)" visible="String.StartsWith(System.BuildVersion,18) + System.HasAddon(script.kodi.loguploader) | System.AddonIsEnabled(script.kodi.loguploader)" /> <!-- Open Kodi Logfile Uploader -->
|
|
||||||
</category>
|
</category>
|
||||||
</settings>
|
</settings>
|
||||||
|
@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
""" Build ZIP files for all brands. """
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
|
|
||||||
DIST_DIR = 'dist'
|
|
||||||
|
|
||||||
|
|
||||||
def get_files():
|
|
||||||
""" Get a list of files that we should package. """
|
|
||||||
# Start with all non-hidden files
|
|
||||||
files = [f for f in os.listdir() if not f.startswith('.')]
|
|
||||||
|
|
||||||
# Exclude files from .gitattributes
|
|
||||||
with open('.gitattributes', 'r') as f:
|
|
||||||
for line in f.read().splitlines():
|
|
||||||
filename, mode = line.split(' ')
|
|
||||||
filename = filename.strip('/')
|
|
||||||
if mode == 'export-ignore' and filename in files:
|
|
||||||
files.remove(filename)
|
|
||||||
|
|
||||||
# Exclude files from .gitignore. I know, this won't do matching
|
|
||||||
with open('.gitignore', 'r') as f:
|
|
||||||
for filename in f.read().splitlines():
|
|
||||||
filename = filename.strip('/')
|
|
||||||
if filename in files:
|
|
||||||
files.remove(filename)
|
|
||||||
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
def modify_xml(file, version, news, python=None):
|
|
||||||
""" Modify an addon.xml. """
|
|
||||||
with open(file, 'r+') as f:
|
|
||||||
tree = ET.fromstring(f.read())
|
|
||||||
|
|
||||||
# Update values
|
|
||||||
tree.set('version', version)
|
|
||||||
tree.find("./extension[@point='xbmc.addon.metadata']/news").text = news
|
|
||||||
if python:
|
|
||||||
tree.find("./requires/import[@addon='xbmc.python']").set('version', python)
|
|
||||||
|
|
||||||
# Save file
|
|
||||||
f.seek(0)
|
|
||||||
f.truncate()
|
|
||||||
f.write('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
|
||||||
ET.tostring(tree, encoding='UTF-8').decode())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Read base addon.xml info
|
|
||||||
with open('addon.xml', 'r') as f:
|
|
||||||
tree = ET.fromstring(f.read())
|
|
||||||
addon_info = {
|
|
||||||
'id': tree.get('id'),
|
|
||||||
'version': tree.get('version'),
|
|
||||||
'news': tree.find("./extension[@point='xbmc.addon.metadata']/news").text
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make sure dist folder exists
|
|
||||||
if not os.path.isdir(DIST_DIR):
|
|
||||||
os.mkdir(DIST_DIR)
|
|
||||||
|
|
||||||
# Build addon
|
|
||||||
brand = addon_info['id']
|
|
||||||
dest = os.path.join(DIST_DIR, brand)
|
|
||||||
if not os.path.isdir(dest):
|
|
||||||
os.mkdir(dest)
|
|
||||||
|
|
||||||
# Copy files from add-on source
|
|
||||||
for f in get_files():
|
|
||||||
if os.path.isfile(f):
|
|
||||||
shutil.copy(f, dest)
|
|
||||||
else:
|
|
||||||
shutil.copytree(f, os.path.join(dest, f), dirs_exist_ok=True)
|
|
||||||
|
|
||||||
# Update addon.xml for matrix and create zip
|
|
||||||
modify_xml(os.path.join(dest, 'addon.xml'), addon_info['version'] + '+matrix.1', addon_info['news'])
|
|
||||||
shutil.make_archive(os.path.join(DIST_DIR, "%s-%s+matrix.1" % (brand, addon_info['version'])), 'zip', DIST_DIR, brand)
|
|
||||||
|
|
||||||
# Modify addon.xml for leia and create zip
|
|
||||||
# modify_xml(os.path.join(dest, 'addon.xml'), addon_info['version'], addon_info['news'], '2.26.0')
|
|
||||||
# shutil.make_archive(os.path.join(DIST_DIR, "%s-%s" % (brand, addon_info['version'])), 'zip', DIST_DIR, brand)
|
|
@ -1,30 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
""" Quick and dirty way to check if all translations might be used. """
|
|
||||||
|
|
||||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name,superfluous-parens
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import polib
|
|
||||||
|
|
||||||
error = 0
|
|
||||||
|
|
||||||
# Load all python code from git
|
|
||||||
code = subprocess.check_output(['git', 'grep', '', '--', 'resources/*.py', 'resources/settings.xml']).decode('utf-8')
|
|
||||||
|
|
||||||
# Load po file
|
|
||||||
po = polib.pofile('resources/language/resource.language.en_gb/strings.po')
|
|
||||||
for entry in po:
|
|
||||||
# Extract msgctxt
|
|
||||||
msgctxt = entry.msgctxt.lstrip('#')
|
|
||||||
|
|
||||||
if msgctxt not in code:
|
|
||||||
print('No usage found for translation:')
|
|
||||||
print(entry)
|
|
||||||
error = 1
|
|
||||||
|
|
||||||
sys.exit(error)
|
|
@ -1,225 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
""" Publish a ZIP file to the Kodi repository. """
|
|
||||||
|
|
||||||
# Based on code from https://github.com/xbmc/kodi-addon-submitter
|
|
||||||
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from pprint import pformat
|
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
from zipfile import ZipFile
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
GH_REPO = 'repo-plugins'
|
|
||||||
GH_USERNAME = os.getenv('GH_USERNAME')
|
|
||||||
GH_TOKEN = os.getenv('GH_TOKEN')
|
|
||||||
GH_EMAIL = os.getenv('EMAIL')
|
|
||||||
|
|
||||||
|
|
||||||
def get_addon_info(xml: str):
|
|
||||||
""" Parse the passed addon.xml file and extract some information. """
|
|
||||||
tree = ET.fromstring(xml)
|
|
||||||
return {
|
|
||||||
'id': tree.get('id'),
|
|
||||||
'name': tree.get('name'),
|
|
||||||
'version': tree.get('version'),
|
|
||||||
'description': tree.find("./extension[@point='xbmc.addon.metadata']/description").text,
|
|
||||||
'news': tree.find("./extension[@point='xbmc.addon.metadata']/news").text,
|
|
||||||
'python': tree.find("./requires/import[@addon='xbmc.python']").get('version'),
|
|
||||||
'source': tree.find("./extension[@point='xbmc.addon.metadata']/source").text,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def user_fork_exists(repo, gh_username, gh_token):
|
|
||||||
""" Check if the user has a fork of the repository on Github. """
|
|
||||||
resp = requests.get(
|
|
||||||
'https://api.github.com/repos/{}/{}'.format(
|
|
||||||
gh_username,
|
|
||||||
repo
|
|
||||||
),
|
|
||||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
|
||||||
params={
|
|
||||||
'type': 'all'
|
|
||||||
},
|
|
||||||
auth=(gh_username, gh_token)
|
|
||||||
)
|
|
||||||
resp_json = resp.json()
|
|
||||||
return resp.ok and resp_json.get('fork')
|
|
||||||
|
|
||||||
|
|
||||||
def create_personal_fork(repo, gh_username, gh_token):
|
|
||||||
"""Create a personal fork for the official repo on GitHub. """
|
|
||||||
resp = requests.post(
|
|
||||||
'https://api.github.com/repos/xbmc/{}/forks'.format(
|
|
||||||
repo
|
|
||||||
),
|
|
||||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
|
||||||
auth=(gh_username, gh_token)
|
|
||||||
)
|
|
||||||
if resp.ok:
|
|
||||||
elapsed_time = 0
|
|
||||||
while elapsed_time < 5 * 60:
|
|
||||||
if not user_fork_exists(repo, gh_username, gh_token):
|
|
||||||
time.sleep(20)
|
|
||||||
elapsed_time += 20
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
raise Exception("Timeout waiting for fork creation exceeded")
|
|
||||||
raise Exception('GitHub API error: {}\n{}'.format(resp.status_code, resp.text))
|
|
||||||
|
|
||||||
|
|
||||||
def shell(*args):
|
|
||||||
""" Execute a shell command. """
|
|
||||||
subprocess.run(args, check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def create_addon_branch(repo, branch, source, addon_info, gh_username, gh_token, gh_email):
|
|
||||||
""" Create and addon branch in your fork of the respective addon repo. """
|
|
||||||
cur_dir = os.getcwd()
|
|
||||||
os.chdir('dist')
|
|
||||||
|
|
||||||
local_branch_name = '{}@{}'.format(addon_info['id'], branch)
|
|
||||||
|
|
||||||
if os.path.isdir(repo):
|
|
||||||
# We already have a checked out repo locally, update this with upstream code
|
|
||||||
os.chdir(repo)
|
|
||||||
shell('git', 'reset', '--hard') # Remove all local changes
|
|
||||||
shell('git', 'remote', 'set-branches', '--add', 'upstream', branch) # Make sure the upstream branch exists
|
|
||||||
shell('git', 'fetch', '-f', 'upstream', branch) # Fetch upstream
|
|
||||||
else:
|
|
||||||
# Clone the upstream repo
|
|
||||||
shell('git', 'clone', '--branch', branch, '--origin', 'upstream', '--single-branch', 'https://github.com/xbmc/{}.git'.format(repo))
|
|
||||||
os.chdir(repo)
|
|
||||||
|
|
||||||
# Create local branch
|
|
||||||
shell('git', 'checkout', '-B', local_branch_name, 'upstream/{}'.format(branch))
|
|
||||||
|
|
||||||
# Remove current code
|
|
||||||
if os.path.isdir(addon_info['id']):
|
|
||||||
shutil.rmtree(addon_info['id'], ignore_errors=False)
|
|
||||||
|
|
||||||
# Add new code
|
|
||||||
shutil.copytree(source, addon_info['id'])
|
|
||||||
shell('git', 'add', '--', addon_info['id'])
|
|
||||||
shell('git', 'status')
|
|
||||||
|
|
||||||
# Create a commit with the new code
|
|
||||||
shell('git', 'config', 'user.name', gh_username)
|
|
||||||
shell('git', 'config', 'user.email', gh_email)
|
|
||||||
shell('git', 'commit', '-m', '[{}] {}'.format(addon_info['id'], addon_info['version']))
|
|
||||||
|
|
||||||
# Push branch to fork
|
|
||||||
shell('git', 'push', '-f', 'https://{}:{}@github.com/{}/{}.git'.format(gh_username, gh_token, gh_username, repo), local_branch_name)
|
|
||||||
|
|
||||||
# Restore working directory
|
|
||||||
os.chdir(cur_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def create_pull_request(repo, branch, addon_info, gh_username, gh_token):
|
|
||||||
""" Create a pull request in the official repo on GitHub. """
|
|
||||||
|
|
||||||
local_branch_name = '{}@{}'.format(addon_info['id'], branch)
|
|
||||||
|
|
||||||
# Check if pull request already exists.
|
|
||||||
resp = requests.get(
|
|
||||||
'https://api.github.com/repos/xbmc/{}/pulls'.format(repo),
|
|
||||||
params={
|
|
||||||
'head': '{}:{}'.format(gh_username, local_branch_name),
|
|
||||||
'base': branch,
|
|
||||||
},
|
|
||||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
|
||||||
auth=(gh_username, gh_token)
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == 200 and not resp.json():
|
|
||||||
# Create a new Pull Request
|
|
||||||
template = """### Description
|
|
||||||
|
|
||||||
- **General**
|
|
||||||
- Add-on name: {name}
|
|
||||||
- Add-on ID: {id}
|
|
||||||
- Version number: {version}
|
|
||||||
- Kodi/repository version: {kodi_repo_branch}
|
|
||||||
|
|
||||||
- **Code location**
|
|
||||||
- URL: {source}
|
|
||||||
|
|
||||||
{description}
|
|
||||||
|
|
||||||
### What's new
|
|
||||||
|
|
||||||
{news}
|
|
||||||
|
|
||||||
### Checklist:
|
|
||||||
- [X] My code follows the [add-on rules](http://kodi.wiki/view/Add-on_rules) and [piracy stance](http://kodi.wiki/view/Official:Forum_rules#Piracy_Policy) of this project.
|
|
||||||
- [X] I have read the [CONTRIBUTING](https://github.com/xbmc/repo-plugins/blob/master/CONTRIBUTING.md) document
|
|
||||||
- [X] Each add-on submission should be a single commit with using the following style: [plugin.video.foo] v1.0.0
|
|
||||||
"""
|
|
||||||
pr_body = template.format(
|
|
||||||
name=addon_info['name'],
|
|
||||||
id=addon_info['id'],
|
|
||||||
version=addon_info['version'],
|
|
||||||
kodi_repo_branch=branch,
|
|
||||||
source=addon_info['source'],
|
|
||||||
description=addon_info['description'],
|
|
||||||
news=addon_info['news']
|
|
||||||
)
|
|
||||||
resp = requests.post(
|
|
||||||
'https://api.github.com/repos/xbmc/{}/pulls'.format(repo),
|
|
||||||
json={
|
|
||||||
'title': '[{}] {}'.format(local_branch_name, addon_info['version']),
|
|
||||||
'head': '{}:{}'.format(gh_username, local_branch_name),
|
|
||||||
'base': branch,
|
|
||||||
'body': pr_body,
|
|
||||||
'maintainer_can_modify': True,
|
|
||||||
},
|
|
||||||
headers={'Accept': 'application/vnd.github.v3+json'},
|
|
||||||
auth=(gh_username, gh_token)
|
|
||||||
)
|
|
||||||
if resp.status_code != 201:
|
|
||||||
raise Exception('GitHub API error: {}\n{}'.format(resp.status_code, pformat(resp.json())))
|
|
||||||
|
|
||||||
elif resp.status_code == 200 and resp.json():
|
|
||||||
_LOGGER.info('Pull request in {} for {}:{} already exists.'.format(branch, gh_username, local_branch_name))
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise Exception('Unexpected GitHub error: {}\n{}'.format(resp.status_code, pformat(resp.json())))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
filenames = sys.argv[1:]
|
|
||||||
|
|
||||||
# Fork the repo if the user does not have a personal repo fork
|
|
||||||
if not user_fork_exists(GH_REPO, GH_USERNAME, GH_TOKEN):
|
|
||||||
create_personal_fork(GH_REPO, GH_USERNAME, GH_TOKEN)
|
|
||||||
|
|
||||||
for filename in filenames:
|
|
||||||
with TemporaryDirectory() as extract_dir:
|
|
||||||
with ZipFile(filename) as z:
|
|
||||||
# Look for addon.xml in zip and load the details
|
|
||||||
xmlfile = next(f.filename for f in z.filelist if f.filename.endswith('addon.xml'))
|
|
||||||
addon_info = get_addon_info(z.read(xmlfile).decode('utf-8'))
|
|
||||||
if addon_info['python'] != '3.0.0':
|
|
||||||
branch = 'leia'
|
|
||||||
else:
|
|
||||||
branch = 'matrix'
|
|
||||||
|
|
||||||
# Extract the ZIP file to the extract_dir
|
|
||||||
z.extractall(extract_dir)
|
|
||||||
|
|
||||||
# Checkout the fork locally and create a branch with our new code from the extract_dir
|
|
||||||
create_addon_branch(GH_REPO, branch, os.path.join(extract_dir, addon_info['id']), addon_info, GH_USERNAME, GH_TOKEN, GH_EMAIL)
|
|
||||||
|
|
||||||
# Create pull request
|
|
||||||
create_pull_request(GH_REPO, branch, addon_info, GH_USERNAME, GH_TOKEN)
|
|
@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# pylint: disable=missing-docstring,no-self-use,wrong-import-order,wrong-import-position,invalid-name
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from glob import glob
|
|
||||||
|
|
||||||
import polib
|
|
||||||
|
|
||||||
original_file = 'resources/language/resource.language.en_gb/strings.po'
|
|
||||||
original = polib.pofile(original_file, wrapwidth=0)
|
|
||||||
|
|
||||||
for translated_file in glob('resources/language/resource.language.*/strings.po'):
|
|
||||||
# Skip original file
|
|
||||||
if translated_file == original_file:
|
|
||||||
continue
|
|
||||||
print('Updating %s...' % translated_file)
|
|
||||||
|
|
||||||
# Load po-files
|
|
||||||
translated = polib.pofile(translated_file, wrapwidth=0)
|
|
||||||
|
|
||||||
for entry in original:
|
|
||||||
# Find a translation
|
|
||||||
translation = translated.find(entry.msgctxt, 'msgctxt')
|
|
||||||
|
|
||||||
if translation and entry.msgid == translation.msgid:
|
|
||||||
entry.msgstr = translation.msgstr
|
|
||||||
|
|
||||||
original.metadata = translated.metadata
|
|
||||||
|
|
||||||
if sys.platform.startswith('win'):
|
|
||||||
# On Windows save the file keeping the Linux return character
|
|
||||||
with open(translated_file, 'wb') as _file:
|
|
||||||
content = str(original).encode('utf-8')
|
|
||||||
content = content.replace(b'\r\n', b'\n')
|
|
||||||
_file.write(content)
|
|
||||||
else:
|
|
||||||
# Save it now over the translation
|
|
||||||
original.save(translated_file)
|
|
@ -4,30 +4,5 @@
|
|||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import xbmcaddon
|
|
||||||
|
|
||||||
try: # Python 3
|
|
||||||
from http.client import HTTPConnection
|
|
||||||
except ImportError: # Python 2
|
|
||||||
from httplib import HTTPConnection
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
# Add logging to urllib
|
|
||||||
HTTPConnection.debuglevel = 1
|
|
||||||
|
|
||||||
# Make UTF-8 the default encoding in Python 2
|
|
||||||
if sys.version_info[0] == 2:
|
|
||||||
reload(sys) # pylint: disable=undefined-variable # noqa: F821
|
|
||||||
sys.setdefaultencoding("utf-8") # pylint: disable=no-member
|
|
||||||
|
|
||||||
# Set credentials based on environment data
|
|
||||||
# Use the .env file with Pipenv to make this work nicely during development
|
|
||||||
ADDON = xbmcaddon.Addon()
|
|
||||||
if os.environ.get('ADDON_USERNAME'):
|
|
||||||
ADDON.setSetting('username', os.environ.get('ADDON_USERNAME'))
|
|
||||||
if os.environ.get('ADDON_PASSWORD'):
|
|
||||||
ADDON.setSetting('password', os.environ.get('ADDON_PASSWORD'))
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<addon id="inputstream.adaptive" version="2.4.4" name="InputStream Adaptive" provider-name="peak3d">
|
|
||||||
</addon>
|
|
@ -1,3 +0,0 @@
|
|||||||
<settings version="2">
|
|
||||||
<setting id="videolibrary.showallitems" default="true">true</setting>
|
|
||||||
</settings>
|
|
11
tests/run.py
@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
""" Run any Kodi plugin:// URL on the commandline """
|
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||||
|
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
""" Run any Kodi VTM GO plugin:// URL on the commandline """
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@ -14,6 +15,12 @@ cwd = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(os.path.real
|
|||||||
sys.path.insert(0, cwd)
|
sys.path.insert(0, cwd)
|
||||||
from resources.lib import addon # noqa: E402 pylint: disable=wrong-import-position
|
from resources.lib import addon # noqa: E402 pylint: disable=wrong-import-position
|
||||||
|
|
||||||
|
xbmc = __import__('xbmc')
|
||||||
|
xbmcaddon = __import__('xbmcaddon')
|
||||||
|
xbmcgui = __import__('xbmcgui')
|
||||||
|
xbmcplugin = __import__('xbmcplugin')
|
||||||
|
xbmcvfs = __import__('xbmcvfs')
|
||||||
|
|
||||||
if len(sys.argv) <= 1:
|
if len(sys.argv) <= 1:
|
||||||
print("%s: URI argument missing\nTry '%s plugin://plugin.video.viervijfzes/' to test." % (sys.argv[0], sys.argv[0]))
|
print("%s: URI argument missing\nTry '%s plugin://plugin.video.viervijfzes/' to test." % (sys.argv[0], sys.argv[0]))
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
@ -9,11 +9,10 @@ import logging
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import resources.lib.kodiutils as kodiutils
|
import resources.lib.kodiutils as kodiutils
|
||||||
from resources.lib.viervijfzes import ResolvedStream
|
|
||||||
from resources.lib.viervijfzes.auth import AuthApi
|
from resources.lib.viervijfzes.auth import AuthApi
|
||||||
from resources.lib.viervijfzes.content import ContentApi, Program, Episode, CACHE_PREVENT, Category
|
from resources.lib.viervijfzes.content import ContentApi, Program, Episode
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('test-api')
|
||||||
|
|
||||||
|
|
||||||
class TestApi(unittest.TestCase):
|
class TestApi(unittest.TestCase):
|
||||||
@ -23,60 +22,27 @@ class TestApi(unittest.TestCase):
|
|||||||
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
||||||
|
|
||||||
def test_programs(self):
|
def test_programs(self):
|
||||||
programs = self._api.get_programs()
|
for channel in ['vier', 'vijf', 'zes']:
|
||||||
self.assertIsInstance(programs, list)
|
programs = self._api.get_programs(channel)
|
||||||
self.assertIsInstance(programs[0], Program)
|
|
||||||
|
|
||||||
def test_popular_programs(self):
|
|
||||||
for brand in [None, 'vier', 'vijf', 'zes', 'goplay']:
|
|
||||||
programs = self._api.get_popular_programs(brand)
|
|
||||||
self.assertIsInstance(programs, list)
|
|
||||||
self.assertIsInstance(programs[0], Program)
|
|
||||||
|
|
||||||
def test_recommendations(self):
|
|
||||||
categories = self._api.get_recommendation_categories()
|
|
||||||
self.assertIsInstance(categories, list)
|
|
||||||
|
|
||||||
def test_categories(self):
|
|
||||||
categories = self._api.get_categories()
|
|
||||||
self.assertIsInstance(categories, list)
|
|
||||||
self.assertIsInstance(categories[0], Category)
|
|
||||||
|
|
||||||
programs = self._api.get_category_content(int(categories[0].uuid))
|
|
||||||
self.assertIsInstance(programs, list)
|
self.assertIsInstance(programs, list)
|
||||||
self.assertIsInstance(programs[0], Program)
|
self.assertIsInstance(programs[0], Program)
|
||||||
|
|
||||||
def test_episodes(self):
|
def test_episodes(self):
|
||||||
for program in ['gentwest', 'zo-man-zo-vrouw']:
|
for channel, program in [('vier', 'auwch'), ('vijf', 'zo-man-zo-vrouw')]:
|
||||||
program = self._api.get_program(program, cache=CACHE_PREVENT)
|
program = self._api.get_program(channel, program)
|
||||||
self.assertIsInstance(program, Program)
|
self.assertIsInstance(program, Program)
|
||||||
self.assertIsInstance(program.seasons, dict)
|
self.assertIsInstance(program.seasons, dict)
|
||||||
self.assertIsInstance(program.episodes, list)
|
self.assertIsInstance(program.episodes, list)
|
||||||
self.assertIsInstance(program.episodes[0], Episode)
|
self.assertIsInstance(program.episodes[0], Episode)
|
||||||
|
|
||||||
def test_clips(self):
|
|
||||||
for program in ['de-tafel-van-vier']:
|
|
||||||
program = self._api.get_program(program, extract_clips=True, cache=CACHE_PREVENT)
|
|
||||||
|
|
||||||
self.assertIsInstance(program.clips, list)
|
|
||||||
self.assertIsInstance(program.clips[0], Episode)
|
|
||||||
|
|
||||||
episode = self._api.get_episode(program.clips[0].path, cache=CACHE_PREVENT)
|
|
||||||
self.assertIsInstance(episode, Episode)
|
|
||||||
|
|
||||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
||||||
def test_get_stream(self):
|
def test_get_stream(self):
|
||||||
program = self._api.get_program('gentwest')
|
program = self._api.get_program('vier', 'auwch')
|
||||||
self.assertIsInstance(program, Program)
|
self.assertIsInstance(program, Program)
|
||||||
|
|
||||||
episode = program.episodes[0]
|
episode = program.episodes[0]
|
||||||
resolved_stream = self._api.get_stream_by_uuid(episode.uuid, episode.islongform)
|
video = self._api.get_stream_by_uuid(episode.uuid)
|
||||||
self.assertIsInstance(resolved_stream, ResolvedStream)
|
self.assertTrue(video)
|
||||||
|
|
||||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
|
||||||
def test_get_drm_stream(self):
|
|
||||||
resolved_stream = self._api.get_stream_by_uuid('cc77be47-0256-4254-acbf-28a03fcac423', True) # https://www.goplay.be/video/ncis-los-angeles/ncis-los-angeles-s14/ncis-los-angeles-s14-aflevering-1
|
|
||||||
self.assertIsInstance(resolved_stream, ResolvedStream)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@ -11,7 +11,7 @@ import unittest
|
|||||||
from resources.lib import kodiutils
|
from resources.lib import kodiutils
|
||||||
from resources.lib.viervijfzes.auth import AuthApi
|
from resources.lib.viervijfzes.auth import AuthApi
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('test-auth')
|
||||||
|
|
||||||
|
|
||||||
class TestAuth(unittest.TestCase):
|
class TestAuth(unittest.TestCase):
|
||||||
@ -26,12 +26,12 @@ class TestAuth(unittest.TestCase):
|
|||||||
auth.clear_tokens()
|
auth.clear_tokens()
|
||||||
|
|
||||||
# We should get a token by logging in
|
# We should get a token by logging in
|
||||||
id_token = auth.get_token()
|
token = auth.get_token()
|
||||||
self.assertTrue(id_token)
|
self.assertTrue(token)
|
||||||
|
|
||||||
# Test it a second time, it should go from memory now
|
# Test it a second time, it should go from memory now
|
||||||
id_token = auth.get_token()
|
token = auth.get_token()
|
||||||
self.assertTrue(id_token)
|
self.assertTrue(token)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
52
tests/test_downloader.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
""" Tests for the episode downloader """
|
||||||
|
|
||||||
|
# pylint: disable=missing-docstring,no-self-use
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from resources.lib import kodiutils
|
||||||
|
from resources.lib.downloader import Downloader
|
||||||
|
from resources.lib.viervijfzes.auth import AuthApi
|
||||||
|
from resources.lib.viervijfzes.content import ContentApi, Program
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger('test-downloader')
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloader(unittest.TestCase):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(TestDownloader, self).__init__(*args, **kwargs)
|
||||||
|
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
||||||
|
self._api = ContentApi(auth, cache_path=kodiutils.get_cache_path())
|
||||||
|
|
||||||
|
def test_check(self):
|
||||||
|
""" Test if ffmpeg is installed. """
|
||||||
|
status = Downloader.check()
|
||||||
|
self.assertTrue(status)
|
||||||
|
|
||||||
|
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
||||||
|
def test_download(self):
|
||||||
|
""" Test to download a stream. """
|
||||||
|
program = self._api.get_program('vier', 'de-mol')
|
||||||
|
self.assertIsInstance(program, Program)
|
||||||
|
|
||||||
|
episode = program.episodes[0]
|
||||||
|
stream = self._api.get_stream_by_uuid(episode.uuid)
|
||||||
|
filename = '/tmp/download-test.mp4'
|
||||||
|
|
||||||
|
def progress_callback(total, seconds):
|
||||||
|
_LOGGER.info('Downloading... Progress = %d / %d seconds', seconds, total)
|
||||||
|
|
||||||
|
# Terminate when we have downloaded 5 seconds, we just want to test this
|
||||||
|
return seconds > 5
|
||||||
|
|
||||||
|
status = Downloader().download(stream=stream, output=filename, progress_callback=progress_callback)
|
||||||
|
self.assertFalse(status) # status is false since we cancelled
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
@ -13,7 +13,7 @@ from resources.lib import kodiutils
|
|||||||
from resources.lib.viervijfzes.content import ContentApi, Episode
|
from resources.lib.viervijfzes.content import ContentApi, Episode
|
||||||
from resources.lib.viervijfzes.epg import EpgApi, EpgProgram
|
from resources.lib.viervijfzes.epg import EpgApi, EpgProgram
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('test-epg')
|
||||||
|
|
||||||
|
|
||||||
class TestEpg(unittest.TestCase):
|
class TestEpg(unittest.TestCase):
|
||||||
@ -22,17 +22,17 @@ class TestEpg(unittest.TestCase):
|
|||||||
self._epg = EpgApi()
|
self._epg = EpgApi()
|
||||||
|
|
||||||
def test_vier_today(self):
|
def test_vier_today(self):
|
||||||
programs = self._epg.get_epg('Play4', date.today().strftime('%Y-%m-%d'))
|
programs = self._epg.get_epg('vier', date.today().strftime('%Y-%m-%d'))
|
||||||
self.assertIsInstance(programs, list)
|
self.assertIsInstance(programs, list)
|
||||||
self.assertIsInstance(programs[0], EpgProgram)
|
self.assertIsInstance(programs[0], EpgProgram)
|
||||||
|
|
||||||
def test_vijf_today(self):
|
def test_vijf_today(self):
|
||||||
programs = self._epg.get_epg('Play5', date.today().strftime('%Y-%m-%d'))
|
programs = self._epg.get_epg('vijf', date.today().strftime('%Y-%m-%d'))
|
||||||
self.assertIsInstance(programs, list)
|
self.assertIsInstance(programs, list)
|
||||||
self.assertIsInstance(programs[0], EpgProgram)
|
self.assertIsInstance(programs[0], EpgProgram)
|
||||||
|
|
||||||
def test_zes_today(self):
|
def test_zes_today(self):
|
||||||
programs = self._epg.get_epg('Play6', date.today().strftime('%Y-%m-%d'))
|
programs = self._epg.get_epg('zes', date.today().strftime('%Y-%m-%d'))
|
||||||
self.assertIsInstance(programs, list)
|
self.assertIsInstance(programs, list)
|
||||||
self.assertIsInstance(programs[0], EpgProgram)
|
self.assertIsInstance(programs[0], EpgProgram)
|
||||||
|
|
||||||
@ -41,31 +41,18 @@ class TestEpg(unittest.TestCase):
|
|||||||
self._epg.get_epg('vtm', date.today().strftime('%Y-%m-%d'))
|
self._epg.get_epg('vtm', date.today().strftime('%Y-%m-%d'))
|
||||||
|
|
||||||
def test_vier_out_of_range(self):
|
def test_vier_out_of_range(self):
|
||||||
programs = self._epg.get_epg('Play4', '2020-01-01')
|
programs = self._epg.get_epg('vier', '2020-01-01')
|
||||||
self.assertEqual(programs, [])
|
self.assertEqual(programs, [])
|
||||||
|
|
||||||
def test_play_video_from_epg(self):
|
def test_play_video_from_epg(self):
|
||||||
epg_programs = self._epg.get_epg('Play4', 'yesterday')
|
epg_programs = self._epg.get_epg('vier', date.today().strftime('%Y-%m-%d'))
|
||||||
epg_program = [program for program in epg_programs if program.video_url][0]
|
epg_program = [program for program in epg_programs if program.video_url][0]
|
||||||
|
|
||||||
# Lookup the Episode data since we don't have an UUID
|
# Lookup the Episode data since we don't have an UUID
|
||||||
api = ContentApi(cache_path=kodiutils.get_cache_path())
|
api = ContentApi(cache_path=kodiutils.get_cache_path())
|
||||||
episode = api.get_episode(epg_program.video_url)
|
episode = api.get_episode(epg_program.channel, epg_program.video_url)
|
||||||
self.assertIsInstance(episode, Episode)
|
self.assertIsInstance(episode, Episode)
|
||||||
|
|
||||||
# def test_map_epg_genre(self):
|
|
||||||
# genres = []
|
|
||||||
# for channel in ['vier', 'vijf', 'zes']:
|
|
||||||
# for day in ['yesterday', 'today', 'tomorrow']:
|
|
||||||
# programs = self._epg.get_epg(channel, day)
|
|
||||||
#
|
|
||||||
# for program in programs:
|
|
||||||
# if program.genre not in genres:
|
|
||||||
# genres.append(program.genre)
|
|
||||||
#
|
|
||||||
# print(genres)
|
|
||||||
#
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
""" Tests for My List """
|
|
||||||
|
|
||||||
# pylint: disable=missing-docstring,no-self-use
|
|
||||||
|
|
||||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from resources.lib import kodiutils
|
|
||||||
from resources.lib.viervijfzes.auth import AuthApi
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMyList(unittest.TestCase):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(TestMyList, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
|
||||||
def test_mylist(self):
|
|
||||||
auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path())
|
|
||||||
id_token = auth.get_token()
|
|
||||||
self.assertTrue(id_token)
|
|
||||||
|
|
||||||
dataset, _ = auth.get_dataset('myList', 'myList')
|
|
||||||
self.assertTrue(dataset)
|
|
||||||
|
|
||||||
# Test disabled since it would cause locks due to all the CI tests changing this at the same time.
|
|
||||||
|
|
||||||
# # Python 2.7 doesn't support .timestamp(), and windows doesn't do '%s', so we need to calculate it ourself
|
|
||||||
# epoch = datetime(1970, 1, 1, tzinfo=dateutil.tz.gettz('UTC'))
|
|
||||||
# now = datetime.now(tz=dateutil.tz.gettz('UTC'))
|
|
||||||
# timestamp = str(int((now - epoch).total_seconds())) + '000'
|
|
||||||
# new_dataset = [
|
|
||||||
# {'id': '06e209f9-092e-421e-9499-58c62c292b98', 'timestamp': timestamp},
|
|
||||||
# {'id': 'da584be3-dea6-49c7-bfbd-c480d8096937', 'timestamp': timestamp}
|
|
||||||
# ]
|
|
||||||
#
|
|
||||||
# auth.put_dataset('myList', 'myList', new_dataset, sync_info)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
@ -9,6 +9,11 @@ import unittest
|
|||||||
|
|
||||||
from resources.lib import addon
|
from resources.lib import addon
|
||||||
|
|
||||||
|
xbmc = __import__('xbmc') # pylint: disable=invalid-name
|
||||||
|
xbmcaddon = __import__('xbmcaddon') # pylint: disable=invalid-name
|
||||||
|
xbmcgui = __import__('xbmcgui') # pylint: disable=invalid-name
|
||||||
|
xbmcplugin = __import__('xbmcplugin') # pylint: disable=invalid-name
|
||||||
|
xbmcvfs = __import__('xbmcvfs') # pylint: disable=invalid-name
|
||||||
|
|
||||||
routing = addon.routing # pylint: disable=invalid-name
|
routing = addon.routing # pylint: disable=invalid-name
|
||||||
|
|
||||||
@ -29,30 +34,30 @@ class TestRouting(unittest.TestCase):
|
|||||||
|
|
||||||
def test_channels_menu(self):
|
def test_channels_menu(self):
|
||||||
routing.run([routing.url_for(addon.show_channels), '0', ''])
|
routing.run([routing.url_for(addon.show_channels), '0', ''])
|
||||||
routing.run([routing.url_for(addon.show_channel_menu, channel='Play4'), '0', ''])
|
routing.run([routing.url_for(addon.show_channel_menu, channel='vier'), '0', ''])
|
||||||
|
|
||||||
def test_catalog_menu(self):
|
def test_catalog_menu(self):
|
||||||
routing.run([routing.url_for(addon.show_catalog), '0', ''])
|
routing.run([routing.url_for(addon.show_catalog), '0', ''])
|
||||||
|
|
||||||
def test_recommendations_menu(self):
|
|
||||||
routing.run([routing.url_for(addon.show_recommendations), '0', ''])
|
|
||||||
|
|
||||||
def test_catalog_channel_menu(self):
|
def test_catalog_channel_menu(self):
|
||||||
routing.run([routing.url_for(addon.show_channel_catalog, channel='Play4'), '0', ''])
|
routing.run([routing.url_for(addon.show_catalog_channel, channel='vier'), '0', ''])
|
||||||
|
|
||||||
def test_catalog_program_menu(self):
|
def test_catalog_program_menu(self):
|
||||||
routing.run([routing.url_for(addon.show_catalog_program, channel='Play4', program='de-mol'), '0', ''])
|
routing.run([routing.url_for(addon.show_catalog_program, channel='vier', program='de-mol'), '0', ''])
|
||||||
|
|
||||||
def test_catalog_program_season_menu(self):
|
def test_catalog_program_season_menu(self):
|
||||||
routing.run([routing.url_for(addon.show_catalog_program_season, channel='Play4', program='de-mol', season=-1), '0', ''])
|
routing.run([routing.url_for(addon.show_catalog_program_season, channel='vier', program='de-mol', season=-1), '0', ''])
|
||||||
|
|
||||||
def test_search_menu(self):
|
def test_search_menu(self):
|
||||||
routing.run([routing.url_for(addon.show_search), '0', ''])
|
routing.run([routing.url_for(addon.show_search), '0', ''])
|
||||||
routing.run([routing.url_for(addon.show_search, query='de mol'), '0', ''])
|
routing.run([routing.url_for(addon.show_search, query='de mol'), '0', ''])
|
||||||
|
|
||||||
def test_tvguide_menu(self):
|
def test_tvguide_menu(self):
|
||||||
routing.run([routing.url_for(addon.show_channel_tvguide, channel='Play4'), '0', ''])
|
routing.run([routing.url_for(addon.show_tvguide_channel, channel='vier'), '0', ''])
|
||||||
routing.run([routing.url_for(addon.show_channel_tvguide_detail, channel='Play4', date='today'), '0', ''])
|
routing.run([routing.url_for(addon.show_tvguide_detail, channel='vier', date='today'), '0', ''])
|
||||||
|
|
||||||
|
def test_metadata_update(self):
|
||||||
|
routing.run([routing.url_for(addon.metadata_update), '0', ''])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@ -11,7 +11,7 @@ import unittest
|
|||||||
from resources.lib.viervijfzes.content import Program
|
from resources.lib.viervijfzes.content import Program
|
||||||
from resources.lib.viervijfzes.search import SearchApi
|
from resources.lib.viervijfzes.search import SearchApi
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger('test-search')
|
||||||
|
|
||||||
|
|
||||||
class TestSearch(unittest.TestCase):
|
class TestSearch(unittest.TestCase):
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
""" Tests for background service """
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name,missing-docstring
|
|
||||||
|
|
||||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
||||||
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from resources.lib import addon, kodiutils
|
|
||||||
from resources.lib.service import BackgroundService
|
|
||||||
|
|
||||||
routing = addon.routing
|
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipIf(sys.platform.startswith("win"), 'Skipping on Windows.')
|
|
||||||
@unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.')
|
|
||||||
class TestService(unittest.TestCase):
|
|
||||||
""" Tests for the background service """
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@pytest.mark.timeout(timeout=10, method='thread')
|
|
||||||
def test_service():
|
|
||||||
""" Run the background service for 5 seconds. It will raise an error when it doesn't stop after 10 seconds. """
|
|
||||||
|
|
||||||
def terminate_service(seconds=5):
|
|
||||||
""" Sleep a bit, and send us a SIGINT signal. """
|
|
||||||
time.sleep(seconds)
|
|
||||||
os.kill(os.getpid(), signal.SIGINT)
|
|
||||||
|
|
||||||
threading.Thread(target=terminate_service).start()
|
|
||||||
|
|
||||||
service = BackgroundService()
|
|
||||||
service.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
10
tests/userdata/addon_settings.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"plugin.video.viervijfzes": {
|
||||||
|
"_comment": "do-not-add-username-and-password-here",
|
||||||
|
"metadata_update": "true"
|
||||||
|
},
|
||||||
|
"plugin.video.youtube": {
|
||||||
|
},
|
||||||
|
"service.upnext": {
|
||||||
|
}
|
||||||
|
}
|
4
tests/userdata/credentials.json.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"username": "username",
|
||||||
|
"password": "password"
|
||||||
|
}
|
6
tests/userdata/global_settings.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"locale.language": "resource.language.nl_nl",
|
||||||
|
"network.bandwidth": 0,
|
||||||
|
"network.usehttpproxy": false,
|
||||||
|
"videolibrary.showallitems": true
|
||||||
|
}
|
247
tests/xbmc.py
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||||
|
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
""" This file implements the Kodi xbmc module, either using stubs or alternative functionality """
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name,no-self-use,unused-argument
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from xbmcextra import global_settings, import_language
|
||||||
|
|
||||||
|
LOGDEBUG = 0
|
||||||
|
LOGERROR = 4
|
||||||
|
LOGFATAL = 6
|
||||||
|
LOGINFO = 1
|
||||||
|
LOGNONE = 7
|
||||||
|
LOGNOTICE = 2
|
||||||
|
LOGSEVERE = 5
|
||||||
|
LOGWARNING = 3
|
||||||
|
|
||||||
|
LOG_MAPPING = {
|
||||||
|
LOGDEBUG: 'Debug',
|
||||||
|
LOGERROR: 'Error',
|
||||||
|
LOGFATAL: 'Fatal',
|
||||||
|
LOGINFO: 'Info',
|
||||||
|
LOGNONE: 'None',
|
||||||
|
LOGNOTICE: 'Notice',
|
||||||
|
LOGSEVERE: 'Severe',
|
||||||
|
LOGWARNING: 'Warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
INFO_LABELS = {
|
||||||
|
'System.BuildVersion': '18.2',
|
||||||
|
}
|
||||||
|
|
||||||
|
REGIONS = {
|
||||||
|
'datelong': '%A, %e %B %Y',
|
||||||
|
'dateshort': '%Y-%m-%d',
|
||||||
|
}
|
||||||
|
|
||||||
|
GLOBAL_SETTINGS = global_settings()
|
||||||
|
PO = import_language(language=GLOBAL_SETTINGS.get('locale.language'))
|
||||||
|
|
||||||
|
|
||||||
|
def to_unicode(text, encoding='utf-8'):
|
||||||
|
""" Force text to unicode """
|
||||||
|
return text.decode(encoding) if isinstance(text, bytes) else text
|
||||||
|
|
||||||
|
|
||||||
|
def from_unicode(text, encoding='utf-8'):
|
||||||
|
""" Force unicode to text """
|
||||||
|
import sys
|
||||||
|
if sys.version_info.major == 2 and isinstance(text, unicode): # noqa: F821; pylint: disable=undefined-variable
|
||||||
|
return text.encode(encoding)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class Keyboard:
|
||||||
|
""" A stub implementation of the xbmc Keyboard class """
|
||||||
|
|
||||||
|
def __init__(self, line='', heading=''):
|
||||||
|
""" A stub constructor for the xbmc Keyboard class """
|
||||||
|
|
||||||
|
def doModal(self, autoclose=0):
|
||||||
|
""" A stub implementation for the xbmc Keyboard class doModal() method """
|
||||||
|
|
||||||
|
def isConfirmed(self):
|
||||||
|
""" A stub implementation for the xbmc Keyboard class isConfirmed() method """
|
||||||
|
return True
|
||||||
|
|
||||||
|
def getText(self):
|
||||||
|
""" A stub implementation for the xbmc Keyboard class getText() method """
|
||||||
|
return 'test'
|
||||||
|
|
||||||
|
|
||||||
|
class Monitor:
|
||||||
|
"""A stub implementation of the xbmc Monitor class"""
|
||||||
|
|
||||||
|
def __init__(self, line='', heading=''):
|
||||||
|
"""A stub constructor for the xbmc Monitor class"""
|
||||||
|
self._deadline = time.time() + 10 # 10 seconds
|
||||||
|
|
||||||
|
def abortRequested(self):
|
||||||
|
"""A stub implementation for the xbmc Keyboard class abortRequested() method"""
|
||||||
|
return time.time() > self._deadline
|
||||||
|
|
||||||
|
def waitForAbort(self, timeout=None):
|
||||||
|
"""A stub implementation for the xbmc Keyboard class waitForAbort() method"""
|
||||||
|
time.sleep(0.5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Player:
|
||||||
|
""" A stub implementation of the xbmc Player class """
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._count = 0
|
||||||
|
|
||||||
|
def play(self, item='', listitem=None, windowed=False, startpos=-1):
|
||||||
|
""" A stub implementation for the xbmc Player class play() method """
|
||||||
|
return
|
||||||
|
|
||||||
|
def isPlaying(self):
|
||||||
|
""" A stub implementation for the xbmc Player class isPlaying() method """
|
||||||
|
# Return True four times out of five
|
||||||
|
self._count += 1
|
||||||
|
return bool(self._count % 5 != 0)
|
||||||
|
|
||||||
|
def setSubtitles(self, subtitleFile):
|
||||||
|
""" A stub implementation for the xbmc Player class setSubtitles() method """
|
||||||
|
return
|
||||||
|
|
||||||
|
def showSubtitles(self, visible):
|
||||||
|
""" A stub implementation for the xbmc Player class showSubtitles() method """
|
||||||
|
return
|
||||||
|
|
||||||
|
def getTotalTime(self):
|
||||||
|
""" A stub implementation for the xbmc Player class getTotalTime() method """
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def getTime(self):
|
||||||
|
""" A stub implementation for the xbmc Player class getTime() method """
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def getVideoInfoTag(self):
|
||||||
|
""" A stub implementation for the xbmc Player class getVideoInfoTag() method """
|
||||||
|
return VideoInfoTag()
|
||||||
|
|
||||||
|
def getPlayingFile(self):
|
||||||
|
""" A stub implementation for the xbmc Player class getPlayingFile() method """
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
class VideoInfoTag:
|
||||||
|
""" A stub implementation of the xbmc VideoInfoTag class """
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
""" A stub constructor for the xbmc VideoInfoTag class """
|
||||||
|
|
||||||
|
def getSeason(self):
|
||||||
|
""" A stub implementation for the xbmc VideoInfoTag class getSeason() method """
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def getEpisode(self):
|
||||||
|
""" A stub implementation for the xbmc VideoInfoTag class getEpisode() method """
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def getTVShowTitle(self):
|
||||||
|
""" A stub implementation for the xbmc VideoInfoTag class getTVShowTitle() method """
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def getPlayCount(self):
|
||||||
|
""" A stub implementation for the xbmc VideoInfoTag class getPlayCount() method """
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def getRating(self):
|
||||||
|
""" A stub implementation for the xbmc VideoInfoTag class getRating() method """
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def executebuiltin(string, wait=False): # pylint: disable=unused-argument
|
||||||
|
""" A stub implementation of the xbmc executebuiltin() function """
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def executeJSONRPC(jsonrpccommand):
|
||||||
|
""" A reimplementation of the xbmc executeJSONRPC() function """
|
||||||
|
command = json.loads(jsonrpccommand)
|
||||||
|
if command.get('method') == 'Settings.GetSettingValue':
|
||||||
|
key = command.get('params').get('setting')
|
||||||
|
return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(value=GLOBAL_SETTINGS.get(key))))
|
||||||
|
if command.get('method') == 'Addons.GetAddonDetails':
|
||||||
|
if command.get('params', {}).get('addonid') == 'script.module.inputstreamhelper':
|
||||||
|
return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(addon=dict(enabled='true', version='0.3.5'))))
|
||||||
|
return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(addon=dict(enabled='true', version='1.2.3'))))
|
||||||
|
if command.get('method') == 'Textures.GetTextures':
|
||||||
|
return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(textures=[dict(cachedurl="", imagehash="", lasthashcheck="", textureid=4837, url="")])))
|
||||||
|
if command.get('method') == 'Textures.RemoveTexture':
|
||||||
|
return json.dumps(dict(id=1, jsonrpc='2.0', result="OK"))
|
||||||
|
log("executeJSONRPC does not implement method '{method}'".format(**command), 'Error')
|
||||||
|
return json.dumps(dict(error=dict(code=-1, message='Not implemented'), id=1, jsonrpc='2.0'))
|
||||||
|
|
||||||
|
|
||||||
|
def getCondVisibility(string):
|
||||||
|
""" A reimplementation of the xbmc getCondVisibility() function """
|
||||||
|
if string == 'system.platform.android':
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def getInfoLabel(key):
|
||||||
|
""" A reimplementation of the xbmc getInfoLabel() function """
|
||||||
|
return INFO_LABELS.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def getLocalizedString(msgctxt):
|
||||||
|
""" A reimplementation of the xbmc getLocalizedString() function """
|
||||||
|
for entry in PO:
|
||||||
|
if entry.msgctxt == '#%s' % msgctxt:
|
||||||
|
return entry.msgstr or entry.msgid
|
||||||
|
if int(msgctxt) >= 30000:
|
||||||
|
log('Unable to translate #{msgctxt}'.format(msgctxt=msgctxt), LOGERROR)
|
||||||
|
return '<Untranslated>'
|
||||||
|
|
||||||
|
|
||||||
|
def getRegion(key):
|
||||||
|
""" A reimplementation of the xbmc getRegion() function """
|
||||||
|
return REGIONS.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg, level=LOGINFO):
|
||||||
|
""" A reimplementation of the xbmc log() function """
|
||||||
|
if level in (LOGERROR, LOGFATAL):
|
||||||
|
print('\033[31;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg)))
|
||||||
|
if level == LOGFATAL:
|
||||||
|
raise Exception(msg)
|
||||||
|
elif level in (LOGWARNING, LOGNOTICE):
|
||||||
|
print('\033[33;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg)))
|
||||||
|
else:
|
||||||
|
print('\033[32;1m%s: \033[32;0m%s\033[0;39m' % (LOG_MAPPING.get(level), to_unicode(msg)))
|
||||||
|
|
||||||
|
|
||||||
|
def setContent(self, content):
|
||||||
|
""" A stub implementation of the xbmc setContent() function """
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def sleep(seconds):
|
||||||
|
""" A reimplementation of the xbmc sleep() function """
|
||||||
|
time.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def translatePath(path):
|
||||||
|
""" A stub implementation of the xbmc translatePath() function """
|
||||||
|
if path.startswith('special://home'):
|
||||||
|
return path.replace('special://home', os.path.join(os.getcwd(), 'tests/'))
|
||||||
|
if path.startswith('special://masterprofile'):
|
||||||
|
return path.replace('special://masterprofile', os.path.join(os.getcwd(), 'tests/userdata/'))
|
||||||
|
if path.startswith('special://profile'):
|
||||||
|
return path.replace('special://profile', os.path.join(os.getcwd(), 'tests/userdata/'))
|
||||||
|
if path.startswith('special://userdata'):
|
||||||
|
return path.replace('special://userdata', os.path.join(os.getcwd(), 'tests/userdata/'))
|
||||||
|
return path
|
76
tests/xbmcaddon.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||||
|
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
"""This file implements the Kodi xbmcaddon module, either using stubs or alternative functionality"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
from xbmc import getLocalizedString
|
||||||
|
from xbmcextra import ADDON_ID, ADDON_INFO, addon_settings
|
||||||
|
|
||||||
|
# Ensure the addon settings are retained (as we don't write to disk)
|
||||||
|
ADDON_SETTINGS = addon_settings(ADDON_ID)
|
||||||
|
|
||||||
|
|
||||||
|
class Addon:
|
||||||
|
"""A reimplementation of the xbmcaddon Addon class"""
|
||||||
|
|
||||||
|
def __init__(self, id=ADDON_ID): # pylint: disable=redefined-builtin
|
||||||
|
"""A stub constructor for the xbmcaddon Addon class"""
|
||||||
|
self.id = id
|
||||||
|
if id == ADDON_ID:
|
||||||
|
self.settings = ADDON_SETTINGS
|
||||||
|
else:
|
||||||
|
self.settings = addon_settings(id)
|
||||||
|
|
||||||
|
def getAddonInfo(self, key):
|
||||||
|
"""A working implementation for the xbmcaddon Addon class getAddonInfo() method"""
|
||||||
|
stub_info = dict(id=self.id, name=self.id, version='2.3.4', type='kodi.inputstream', profile='special://userdata', path='special://userdata')
|
||||||
|
# Add stub_info values to ADDON_INFO when missing (e.g. path and profile)
|
||||||
|
addon_info = dict(stub_info, **ADDON_INFO)
|
||||||
|
return addon_info.get(self.id, stub_info).get(key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getLocalizedString(msgctxt):
|
||||||
|
"""A working implementation for the xbmcaddon Addon class getLocalizedString() method"""
|
||||||
|
return getLocalizedString(msgctxt)
|
||||||
|
|
||||||
|
def getSetting(self, key):
|
||||||
|
"""A working implementation for the xbmcaddon Addon class getSetting() method"""
|
||||||
|
return self.settings.get(key, '')
|
||||||
|
|
||||||
|
def getSettingBool(self, key):
|
||||||
|
"""A working implementation for the xbmcaddon Addon class getSettingBool() method"""
|
||||||
|
return bool(self.settings.get(key, False))
|
||||||
|
|
||||||
|
def getSettingInt(self, key):
|
||||||
|
"""A working implementation for the xbmcaddon Addon class getSettingInt() method"""
|
||||||
|
return int(self.settings.get(key, 0))
|
||||||
|
|
||||||
|
def getSettingNumber(self, key):
|
||||||
|
"""A working implementation for the xbmcaddon Addon class getSettingNumber() method"""
|
||||||
|
return float(self.settings.get(key, 0.0))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def openSettings():
|
||||||
|
"""A stub implementation for the xbmcaddon Addon class openSettings() method"""
|
||||||
|
|
||||||
|
def setSetting(self, key, value):
|
||||||
|
"""A stub implementation for the xbmcaddon Addon class setSetting() method"""
|
||||||
|
self.settings[key] = value
|
||||||
|
# NOTE: Disable actual writing as it is no longer needed for testing
|
||||||
|
# with open('tests/userdata/addon_settings.json', 'w') as fd:
|
||||||
|
# json.dump(filtered_settings, fd, sort_keys=True, indent=4)
|
||||||
|
|
||||||
|
def setSettingBool(self, key, value):
|
||||||
|
"""A stub implementation for the xbmcaddon Addon class setSettingBool() method"""
|
||||||
|
self.settings[key] = value
|
||||||
|
|
||||||
|
def setSettingInt(self, key, value):
|
||||||
|
"""A stub implementation for the xbmcaddon Addon class setSettingInt() method"""
|
||||||
|
self.settings[key] = value
|
||||||
|
|
||||||
|
def setSettingNumber(self, key, value):
|
||||||
|
"""A stub implementation for the xbmcaddon Addon class setSettingNumber() method"""
|
||||||
|
self.settings[key] = value
|
188
tests/xbmcextra.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||||
|
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
"""Extra functions for testing"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
import os
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import polib
|
||||||
|
|
||||||
|
|
||||||
|
def kodi_to_ansi(string):
|
||||||
|
"""Convert Kodi format tags to ANSI codes"""
|
||||||
|
if string is None:
|
||||||
|
return None
|
||||||
|
string = string.replace('[B]', '\033[1m')
|
||||||
|
string = string.replace('[/B]', '\033[21m')
|
||||||
|
string = string.replace('[I]', '\033[3m')
|
||||||
|
string = string.replace('[/I]', '\033[23m')
|
||||||
|
string = string.replace('[COLOR gray]', '\033[30;1m')
|
||||||
|
string = string.replace('[COLOR red]', '\033[31m')
|
||||||
|
string = string.replace('[COLOR green]', '\033[32m')
|
||||||
|
string = string.replace('[COLOR yellow]', '\033[33m')
|
||||||
|
string = string.replace('[COLOR blue]', '\033[34m')
|
||||||
|
string = string.replace('[COLOR purple]', '\033[35m')
|
||||||
|
string = string.replace('[COLOR cyan]', '\033[36m')
|
||||||
|
string = string.replace('[COLOR white]', '\033[37m')
|
||||||
|
string = string.replace('[/COLOR]', '\033[39;0m')
|
||||||
|
return string
|
||||||
|
|
||||||
|
|
||||||
|
def uri_to_path(uri):
|
||||||
|
"""Shorten a plugin URI to just the path"""
|
||||||
|
if uri is None:
|
||||||
|
return None
|
||||||
|
return ' \033[33m→ \033[34m%s\033[39;0m' % uri.replace('plugin://' + ADDON_ID, '')
|
||||||
|
|
||||||
|
|
||||||
|
def read_addon_xml(path):
|
||||||
|
"""Parse the addon.xml and return an info dictionary"""
|
||||||
|
info = dict(
|
||||||
|
path='./', # '/storage/.kodi/addons/plugin.video.vrt.nu',
|
||||||
|
profile='special://userdata', # 'special://profile/addon_data/plugin.video.vrt.nu/',
|
||||||
|
type='xbmc.python.pluginsource',
|
||||||
|
)
|
||||||
|
|
||||||
|
tree = ET.parse(path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
info.update(root.attrib) # Add 'id', 'name' and 'version'
|
||||||
|
info['author'] = info.pop('provider-name')
|
||||||
|
|
||||||
|
for child in root:
|
||||||
|
if child.attrib.get('point') != 'xbmc.addon.metadata':
|
||||||
|
continue
|
||||||
|
for grandchild in child:
|
||||||
|
# Handle assets differently
|
||||||
|
if grandchild.tag == 'assets':
|
||||||
|
for asset in grandchild:
|
||||||
|
info[asset.tag] = asset.text
|
||||||
|
continue
|
||||||
|
# Not in English ? Drop it
|
||||||
|
if grandchild.attrib.get('lang', 'en_GB') != 'en_GB':
|
||||||
|
continue
|
||||||
|
# Add metadata
|
||||||
|
info[grandchild.tag] = grandchild.text
|
||||||
|
|
||||||
|
return {info['name']: info}
|
||||||
|
|
||||||
|
|
||||||
|
def global_settings():
|
||||||
|
"""Use the global_settings file"""
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
with open('tests/userdata/global_settings.json') as f:
|
||||||
|
settings = json.load(f)
|
||||||
|
except OSError as e:
|
||||||
|
print("Error: Cannot use 'tests/userdata/global_settings.json' : %s" % e)
|
||||||
|
settings = {
|
||||||
|
'locale.language': 'resource.language.en_gb',
|
||||||
|
'network.bandwidth': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if 'PROXY_SERVER' in os.environ:
|
||||||
|
settings['network.usehttpproxy'] = True
|
||||||
|
settings['network.httpproxytype'] = 0
|
||||||
|
print('Using proxy server from environment variable PROXY_SERVER')
|
||||||
|
settings['network.httpproxyserver'] = os.environ.get('PROXY_SERVER')
|
||||||
|
if 'PROXY_PORT' in os.environ:
|
||||||
|
print('Using proxy server from environment variable PROXY_PORT')
|
||||||
|
settings['network.httpproxyport'] = os.environ.get('PROXY_PORT')
|
||||||
|
if 'PROXY_USERNAME' in os.environ:
|
||||||
|
print('Using proxy server from environment variable PROXY_USERNAME')
|
||||||
|
settings['network.httpproxyusername'] = os.environ.get('PROXY_USERNAME')
|
||||||
|
if 'PROXY_PASSWORD' in os.environ:
|
||||||
|
print('Using proxy server from environment variable PROXY_PASSWORD')
|
||||||
|
settings['network.httpproxypassword'] = os.environ.get('PROXY_PASSWORD')
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def addon_settings(addon_id=None):
|
||||||
|
"""Use the addon_settings file"""
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
with open('tests/userdata/addon_settings.json') as f:
|
||||||
|
settings = json.load(f)
|
||||||
|
except OSError as e:
|
||||||
|
print("Error: Cannot use 'tests/userdata/addon_settings.json' : %s" % e)
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
# Read credentials from environment or credentials.json
|
||||||
|
if 'ADDON_USERNAME' in os.environ and 'ADDON_PASSWORD' in os.environ:
|
||||||
|
# print('Using credentials from the environment variables ADDON_USERNAME and ADDON_PASSWORD')
|
||||||
|
settings[ADDON_ID]['username'] = os.environ.get('ADDON_USERNAME')
|
||||||
|
settings[ADDON_ID]['password'] = os.environ.get('ADDON_PASSWORD')
|
||||||
|
elif os.path.exists('tests/userdata/credentials.json'):
|
||||||
|
# print('Using credentials from tests/userdata/credentials.json')
|
||||||
|
with open('tests/userdata/credentials.json') as f:
|
||||||
|
credentials = json.load(f)
|
||||||
|
settings[ADDON_ID].update(credentials)
|
||||||
|
else:
|
||||||
|
print("Error: Cannot use 'tests/userdata/credentials.json'")
|
||||||
|
|
||||||
|
if addon_id:
|
||||||
|
return settings[addon_id]
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def import_language(language):
|
||||||
|
"""Process the language.po file"""
|
||||||
|
try:
|
||||||
|
podb = polib.pofile('resources/language/{language}/strings.po'.format(language=language))
|
||||||
|
except IOError:
|
||||||
|
podb = polib.pofile('resources/language/resource.language.en_gb/strings.po')
|
||||||
|
|
||||||
|
podb.extend([
|
||||||
|
# WEEKDAY_LONG
|
||||||
|
polib.POEntry(msgctxt='#11', msgstr='Monday'),
|
||||||
|
polib.POEntry(msgctxt='#12', msgstr='Tuesday'),
|
||||||
|
polib.POEntry(msgctxt='#13', msgstr='Wednesday'),
|
||||||
|
polib.POEntry(msgctxt='#14', msgstr='Thursday'),
|
||||||
|
polib.POEntry(msgctxt='#15', msgstr='Friday'),
|
||||||
|
polib.POEntry(msgctxt='#16', msgstr='Saturday'),
|
||||||
|
polib.POEntry(msgctxt='#17', msgstr='Sunday'),
|
||||||
|
# MONTH_LONG
|
||||||
|
polib.POEntry(msgctxt='#21', msgstr='January'),
|
||||||
|
polib.POEntry(msgctxt='#22', msgstr='February'),
|
||||||
|
polib.POEntry(msgctxt='#23', msgstr='March'),
|
||||||
|
polib.POEntry(msgctxt='#24', msgstr='April'),
|
||||||
|
polib.POEntry(msgctxt='#25', msgstr='May'),
|
||||||
|
polib.POEntry(msgctxt='#26', msgstr='June'),
|
||||||
|
polib.POEntry(msgctxt='#27', msgstr='July'),
|
||||||
|
polib.POEntry(msgctxt='#28', msgstr='August'),
|
||||||
|
polib.POEntry(msgctxt='#29', msgstr='September'),
|
||||||
|
polib.POEntry(msgctxt='#30', msgstr='October'),
|
||||||
|
polib.POEntry(msgctxt='#31', msgstr='November'),
|
||||||
|
polib.POEntry(msgctxt='#32', msgstr='December'),
|
||||||
|
# WEEKDAY_SHORT
|
||||||
|
polib.POEntry(msgctxt='#41', msgstr='Mon'),
|
||||||
|
polib.POEntry(msgctxt='#42', msgstr='Tue'),
|
||||||
|
polib.POEntry(msgctxt='#43', msgstr='Wed'),
|
||||||
|
polib.POEntry(msgctxt='#44', msgstr='Thu'),
|
||||||
|
polib.POEntry(msgctxt='#45', msgstr='Fri'),
|
||||||
|
polib.POEntry(msgctxt='#46', msgstr='Sat'),
|
||||||
|
polib.POEntry(msgctxt='#47', msgstr='Sun'),
|
||||||
|
# MONTH_LONG
|
||||||
|
polib.POEntry(msgctxt='#51', msgstr='Jan'),
|
||||||
|
polib.POEntry(msgctxt='#52', msgstr='Feb'),
|
||||||
|
polib.POEntry(msgctxt='#53', msgstr='Mar'),
|
||||||
|
polib.POEntry(msgctxt='#54', msgstr='Apr'),
|
||||||
|
polib.POEntry(msgctxt='#55', msgstr='May'),
|
||||||
|
polib.POEntry(msgctxt='#56', msgstr='Jun'),
|
||||||
|
polib.POEntry(msgctxt='#57', msgstr='Jul'),
|
||||||
|
polib.POEntry(msgctxt='#58', msgstr='Aug'),
|
||||||
|
polib.POEntry(msgctxt='#59', msgstr='Sep'),
|
||||||
|
polib.POEntry(msgctxt='#50', msgstr='Oct'),
|
||||||
|
polib.POEntry(msgctxt='#51', msgstr='Nov'),
|
||||||
|
polib.POEntry(msgctxt='#52', msgstr='Dec'),
|
||||||
|
])
|
||||||
|
|
||||||
|
return podb
|
||||||
|
|
||||||
|
|
||||||
|
ADDON_INFO = read_addon_xml('addon.xml')
|
||||||
|
ADDON_ID = next(iter(list(ADDON_INFO.values()))).get('id')
|
327
tests/xbmcgui.py
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||||
|
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
"""This file implements the Kodi xbmcgui module, either using stubs or alternative functionality"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name,super-on-old-class,too-many-arguments,unused-argument,useless-super-delegation
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
import sys
|
||||||
|
from xbmcextra import kodi_to_ansi
|
||||||
|
|
||||||
|
|
||||||
|
class Control:
|
||||||
|
"""A reimplementation of the xbmcgui Control class"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""A stub constructor for the xbmcgui Control class"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def selectItem(index):
|
||||||
|
"""A stub implementation for the xbmcgui Control class selectItem() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class ControlLabel(Control):
|
||||||
|
"""A reimplementation of the xbmcgui ControlLabel class"""
|
||||||
|
|
||||||
|
def __init__(self): # pylint: disable=super-init-not-called
|
||||||
|
"""A stub constructor for the xbmcgui ControlLabel class"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getLabel():
|
||||||
|
"""A stub implementation for the xbmcgui ControlLabel class getLabel() method"""
|
||||||
|
return 'Label'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setLabel(label='', font=None, textColor=None, disabledColor=None, shadowColor=None, focusedColor=None, label2=''):
|
||||||
|
"""A stub implementation for the xbmcgui ControlLabel class getLabel() method"""
|
||||||
|
|
||||||
|
|
||||||
|
class Dialog:
|
||||||
|
"""A reimplementation of the xbmcgui Dialog class"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""A stub constructor for the xbmcgui Dialog class"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def notification(heading, message, icon=None, time=None, sound=None):
|
||||||
|
"""A working implementation for the xbmcgui Dialog class notification() method"""
|
||||||
|
heading = kodi_to_ansi(heading)
|
||||||
|
message = kodi_to_ansi(message)
|
||||||
|
print('\033[37;44;1mNOTIFICATION:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ok(heading, message='', line1='', line2='', line3=''):
|
||||||
|
"""A stub implementation for the xbmcgui Dialog class ok() method"""
|
||||||
|
heading = kodi_to_ansi(heading)
|
||||||
|
message = kodi_to_ansi(message)
|
||||||
|
print('\033[37;44;1mOK:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message or line1))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def info(listitem):
|
||||||
|
"""A stub implementation for the xbmcgui Dialog class info() method"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def select(heading, opt_list, autoclose=0, preselect=None, useDetails=False):
|
||||||
|
"""A stub implementation for the xbmcgui Dialog class select() method"""
|
||||||
|
if preselect is None:
|
||||||
|
preselect = []
|
||||||
|
heading = kodi_to_ansi(heading)
|
||||||
|
print('\033[37;44;1mSELECT:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, ', '.join(opt_list)))
|
||||||
|
return -1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def multiselect(heading, options, autoclose=0, preselect=None, useDetails=False): # pylint: disable=useless-return
|
||||||
|
"""A stub implementation for the xbmcgui Dialog class multiselect() method"""
|
||||||
|
if preselect is None:
|
||||||
|
preselect = []
|
||||||
|
heading = kodi_to_ansi(heading)
|
||||||
|
print('\033[37;44;1mMULTISELECT:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, ', '.join(options)))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def contextmenu(items):
|
||||||
|
"""A stub implementation for the xbmcgui Dialog class contextmenu() method"""
|
||||||
|
print('\033[37;44;1mCONTEXTMENU:\033[35;49;1m \033[37;1m%s\033[39;0m' % (', '.join(items)))
|
||||||
|
return -1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def yesno(heading, message='', line1='', line2='', line3='', nolabel=None, yeslabel=None, autoclose=0):
|
||||||
|
"""A stub implementation for the xbmcgui Dialog class yesno() method"""
|
||||||
|
heading = kodi_to_ansi(heading)
|
||||||
|
message = kodi_to_ansi(message)
|
||||||
|
print('\033[37;44;1mYESNO:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message or line1))
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def textviewer(heading, text=None, usemono=None):
|
||||||
|
"""A stub implementation for the xbmcgui Dialog class textviewer() method"""
|
||||||
|
heading = kodi_to_ansi(heading)
|
||||||
|
text = kodi_to_ansi(text)
|
||||||
|
print('\033[37;44;1mTEXTVIEWER:\033[35;49;1m [%s]\n\033[37;1m%s\033[39;0m' % (heading, text))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def browseSingle(type, heading, shares, mask=None, useThumbs=None, treatAsFolder=None, defaultt=None): # pylint: disable=redefined-builtin
|
||||||
|
"""A stub implementation for the xbmcgui Dialog class browseSingle() method"""
|
||||||
|
print('\033[37;44;1mBROWSESINGLE:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (type, heading))
|
||||||
|
return 'special://masterprofile/addon_data/script.module.inputstreamhelper/'
|
||||||
|
|
||||||
|
|
||||||
|
class DialogProgress:
|
||||||
|
"""A reimplementation of the xbmcgui DialogProgress"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""A stub constructor for the xbmcgui DialogProgress class"""
|
||||||
|
self.percent = 0
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""A stub implementation for the xbmcgui DialogProgress class close() method"""
|
||||||
|
self.percent = 0
|
||||||
|
print()
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def create(self, heading, message='', line1='', line2='', line3=''):
|
||||||
|
"""A stub implementation for the xbmcgui DialogProgress class create() method"""
|
||||||
|
self.percent = 0
|
||||||
|
heading = kodi_to_ansi(heading)
|
||||||
|
line1 = kodi_to_ansi(line1)
|
||||||
|
print('\033[37;44;1mPROGRESS:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message or line1))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def iscanceled(self):
|
||||||
|
"""A stub implementation for the xbmcgui DialogProgress class iscanceled() method"""
|
||||||
|
return self.percent > 5 # Cancel at 5%
|
||||||
|
|
||||||
|
def update(self, percent, message='', line1='', line2='', line3=''):
|
||||||
|
"""A stub implementation for the xbmcgui DialogProgress class update() method"""
|
||||||
|
if (percent - 5) < self.percent:
|
||||||
|
return
|
||||||
|
self.percent = percent
|
||||||
|
line1 = kodi_to_ansi(line1)
|
||||||
|
line2 = kodi_to_ansi(line2)
|
||||||
|
line3 = kodi_to_ansi(line3)
|
||||||
|
if line1 or line2 or line3:
|
||||||
|
print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%] \033[37;1m%s\033[39;0m' % (percent, message or line1 or line2 or line3), end='')
|
||||||
|
else:
|
||||||
|
print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%]\033[39;0m' % (percent), end='')
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
class DialogProgressBG:
|
||||||
|
"""A reimplementation of the xbmcgui DialogProgressBG"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""A stub constructor for the xbmcgui DialogProgressBG class"""
|
||||||
|
self.percent = 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def close():
|
||||||
|
"""A stub implementation for the xbmcgui DialogProgressBG class close() method"""
|
||||||
|
print()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create(heading, message):
|
||||||
|
"""A stub implementation for the xbmcgui DialogProgressBG class create() method"""
|
||||||
|
heading = kodi_to_ansi(heading)
|
||||||
|
message = kodi_to_ansi(message)
|
||||||
|
print('\033[37;44;1mPROGRESS:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def isfinished():
|
||||||
|
"""A stub implementation for the xbmcgui DialogProgressBG class isfinished() method"""
|
||||||
|
|
||||||
|
def update(self, percent, heading=None, message=None):
|
||||||
|
"""A stub implementation for the xbmcgui DialogProgressBG class update() method"""
|
||||||
|
if (percent - 5) < self.percent:
|
||||||
|
return
|
||||||
|
self.percent = percent
|
||||||
|
message = kodi_to_ansi(message)
|
||||||
|
if message:
|
||||||
|
print('\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%] \033[37;1m%s\033[39;0m' % (percent, message))
|
||||||
|
else:
|
||||||
|
print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%]\033[39;0m' % (percent), end='')
|
||||||
|
|
||||||
|
|
||||||
|
class DialogBusy:
|
||||||
|
"""A reimplementation of the xbmcgui DialogBusy"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""A stub constructor for the xbmcgui DialogBusy class"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def close():
|
||||||
|
"""A stub implementation for the xbmcgui DialogBusy class close() method"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create():
|
||||||
|
"""A stub implementation for the xbmcgui DialogBusy class create() method"""
|
||||||
|
|
||||||
|
|
||||||
|
class ListItem:
|
||||||
|
"""A reimplementation of the xbmcgui ListItem class"""
|
||||||
|
|
||||||
|
def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path='', offscreen=False):
|
||||||
|
"""A stub constructor for the xbmcgui ListItem class"""
|
||||||
|
self.label = kodi_to_ansi(label)
|
||||||
|
self.label2 = kodi_to_ansi(label2)
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def addContextMenuItems(items, replaceItems=False):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class addContextMenuItems() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def addStreamInfo(stream_type, stream_values):
|
||||||
|
"""A stub implementation for the xbmcgui LitItem class addStreamInfo() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setArt(key):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setArt() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setContentLookup(enable):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setContentLookup() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setInfo(type, infoLabels): # pylint: disable=redefined-builtin
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setInfo() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setIsFolder(isFolder):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setIsFolder() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setMimeType(mimetype):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setMimeType() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
def setPath(self, path):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setPath() method"""
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setProperty(key, value):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setProperty() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setProperties(dictionary):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setProperties() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setSubtitles(subtitleFiles):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setSubtitles() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setUniqueIDs(values, defaultrating=None):
|
||||||
|
"""A stub implementation for the xbmcgui ListItem class setUniqueIDs() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
class Window:
|
||||||
|
"""A reimplementation of the xbmcgui Window"""
|
||||||
|
|
||||||
|
def __init__(self, windowId):
|
||||||
|
"""A stub constructor for the xbmcgui Window class"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""A stub implementation for the xbmcgui Window class close() method"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getControl():
|
||||||
|
"""A stub implementation for the xbmcgui Window class getControl() method"""
|
||||||
|
return ControlLabel()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getFocusId():
|
||||||
|
"""A stub implementation for the xbmcgui Window class getFocusId() method"""
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getProperty(key):
|
||||||
|
"""A stub implementation for the xbmcgui Window class getProperty() method"""
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setProperty(key, value):
|
||||||
|
"""A stub implementation for the xbmcgui Window class setProperty() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clearProperty(key):
|
||||||
|
"""A stub implementation for the xbmcgui Window class clearProperty() method"""
|
||||||
|
return
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
"""A stub implementation for the xbmcgui Window class show() method"""
|
||||||
|
|
||||||
|
|
||||||
|
class WindowXML(Window):
|
||||||
|
"""A reimplementation of the xbmcgui WindowXML"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""A stub constructor for the xbmcgui WindowXML class"""
|
||||||
|
super(WindowXML, self).__init__()
|
||||||
|
|
||||||
|
|
||||||
|
class WindowXMLDialog(WindowXML):
|
||||||
|
"""A reimplementation of the xbmcgui WindowXMLDialog"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""A stub constructor for the xbmcgui WindowXMLDialog class"""
|
||||||
|
super(WindowXMLDialog, self).__init__()
|
||||||
|
|
||||||
|
|
||||||
|
def getCurrentWindowId():
|
||||||
|
"""A stub implementation of the xbmcgui getCurrentWindowId() method"""
|
||||||
|
return 0
|
112
tests/xbmcplugin.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||||
|
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
"""This file implements the Kodi xbmcplugin module, either using stubs or alternative functionality"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name,unused-argument
|
||||||
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
|
||||||
|
from xbmc import LOGFATAL, LOGINFO, log
|
||||||
|
from xbmcextra import kodi_to_ansi, uri_to_path
|
||||||
|
|
||||||
|
try: # Python 3
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
except ImportError: # Python 2
|
||||||
|
from urllib2 import HTTPError, Request, urlopen
|
||||||
|
|
||||||
|
SORT_METHOD_NONE = 0
|
||||||
|
SORT_METHOD_LABEL = 1
|
||||||
|
SORT_METHOD_LABEL_IGNORE_THE = 2
|
||||||
|
SORT_METHOD_DATE = 3
|
||||||
|
SORT_METHOD_SIZE = 4
|
||||||
|
SORT_METHOD_FILE = 5
|
||||||
|
SORT_METHOD_DRIVE_TYPE = 6
|
||||||
|
SORT_METHOD_TRACKNUM = 7
|
||||||
|
SORT_METHOD_DURATION = 8
|
||||||
|
SORT_METHOD_TITLE = 9
|
||||||
|
SORT_METHOD_TITLE_IGNORE_THE = 10
|
||||||
|
SORT_METHOD_ARTIST = 11
|
||||||
|
SORT_METHOD_ARTIST_AND_YEAR = 12
|
||||||
|
SORT_METHOD_ARTIST_IGNORE_THE = 13
|
||||||
|
SORT_METHOD_ALBUM = 14
|
||||||
|
SORT_METHOD_ALBUM_IGNORE_THE = 15
|
||||||
|
SORT_METHOD_GENRE = 16
|
||||||
|
SORT_METHOD_COUNTRY = 17
|
||||||
|
SORT_METHOD_VIDEO_YEAR = 18 # This is SORT_METHOD_YEAR in Kodi
|
||||||
|
SORT_METHOD_VIDEO_RATING = 19
|
||||||
|
SORT_METHOD_VIDEO_USER_RATING = 20
|
||||||
|
SORT_METHOD_DATEADDED = 21
|
||||||
|
SORT_METHOD_PROGRAM_COUNT = 22
|
||||||
|
SORT_METHOD_PLAYLIST_ORDER = 23
|
||||||
|
SORT_METHOD_EPISODE = 24
|
||||||
|
SORT_METHOD_VIDEO_TITLE = 25
|
||||||
|
SORT_METHOD_VIDEO_SORT_TITLE = 26
|
||||||
|
SORT_METHOD_VIDEO_SORT_TITLE_IGNORE_THE = 27
|
||||||
|
SORT_METHOD_PRODUCTIONCODE = 28
|
||||||
|
SORT_METHOD_SONG_RATING = 29
|
||||||
|
SORT_METHOD_SONG_USER_RATING = 30
|
||||||
|
SORT_METHOD_MPAA_RATING = 31
|
||||||
|
SORT_METHOD_VIDEO_RUNTIME = 32
|
||||||
|
SORT_METHOD_STUDIO = 33
|
||||||
|
SORT_METHOD_STUDIO_IGNORE_THE = 34
|
||||||
|
SORT_METHOD_FULLPATH = 35
|
||||||
|
SORT_METHOD_LABEL_IGNORE_FOLDERS = 36
|
||||||
|
SORT_METHOD_LASTPLAYED = 37
|
||||||
|
SORT_METHOD_PLAYCOUNT = 38
|
||||||
|
SORT_METHOD_LISTENERS = 39
|
||||||
|
SORT_METHOD_UNSORTED = 40
|
||||||
|
SORT_METHOD_CHANNEL = 41
|
||||||
|
SORT_METHOD_CHANNEL_NUMBER = 42
|
||||||
|
SORT_METHOD_BITRATE = 43
|
||||||
|
SORT_METHOD_DATE_TAKEN = 44
|
||||||
|
|
||||||
|
|
||||||
|
def addDirectoryItem(handle, path, listitem, isFolder=False):
|
||||||
|
"""A reimplementation of the xbmcplugin addDirectoryItems() function"""
|
||||||
|
label = kodi_to_ansi(listitem.label)
|
||||||
|
path = uri_to_path(path) if path else ''
|
||||||
|
# perma = kodi_to_ansi(listitem.label) # FIXME: Add permalink
|
||||||
|
bullet = '»' if isFolder else '·'
|
||||||
|
print('{bullet} {label}{path}'.format(bullet=bullet, label=label, path=path))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def addDirectoryItems(handle, listing, length):
|
||||||
|
"""A reimplementation of the xbmcplugin addDirectoryItems() function"""
|
||||||
|
for item in listing:
|
||||||
|
addDirectoryItem(handle, item[0], item[1], item[2])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def addSortMethod(handle, sortMethod):
|
||||||
|
"""A stub implementation of the xbmcplugin addSortMethod() function"""
|
||||||
|
|
||||||
|
|
||||||
|
def endOfDirectory(handle, succeeded=True, updateListing=True, cacheToDisc=True):
|
||||||
|
"""A stub implementation of the xbmcplugin endOfDirectory() function"""
|
||||||
|
# print(kodi_to_ansi('[B]-=( [COLOR cyan]--------[/COLOR] )=-[/B]'))
|
||||||
|
|
||||||
|
|
||||||
|
def setContent(handle, content):
|
||||||
|
"""A stub implementation of the xbmcplugin setContent() function"""
|
||||||
|
|
||||||
|
|
||||||
|
def setPluginFanart(handle, image, color1=None, color2=None, color3=None):
|
||||||
|
"""A stub implementation of the xbmcplugin setPluginFanart() function"""
|
||||||
|
|
||||||
|
|
||||||
|
def setPluginCategory(handle, category):
|
||||||
|
"""A reimplementation of the xbmcplugin setPluginCategory() function"""
|
||||||
|
print(kodi_to_ansi('[B]-=( [COLOR cyan]%s[/COLOR] )=-[/B]' % category))
|
||||||
|
|
||||||
|
|
||||||
|
def setResolvedUrl(handle, succeeded, listitem):
|
||||||
|
"""A stub implementation of the xbmcplugin setResolvedUrl() function"""
|
||||||
|
request = Request(listitem.path)
|
||||||
|
request.get_method = lambda: 'HEAD'
|
||||||
|
try:
|
||||||
|
response = urlopen(request)
|
||||||
|
log('Stream playing successfully: %s' % response.code, LOGINFO)
|
||||||
|
except HTTPError as exc:
|
||||||
|
log('Playing stream returned: %s' % exc, LOGFATAL)
|
80
tests/xbmcvfs.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||||
|
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
"""This file implements the Kodi xbmcvfs module, either using stubs or alternative functionality"""
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
import os
|
||||||
|
from shutil import copyfile
|
||||||
|
|
||||||
|
|
||||||
|
def File(path, flags='r'):
|
||||||
|
"""A reimplementation of the xbmcvfs File() function"""
|
||||||
|
return open(path, flags)
|
||||||
|
|
||||||
|
|
||||||
|
def Stat(path):
|
||||||
|
"""A reimplementation of the xbmcvfs Stat() function"""
|
||||||
|
|
||||||
|
class stat:
|
||||||
|
"""A reimplementation of the xbmcvfs stat class"""
|
||||||
|
|
||||||
|
def __init__(self, path):
|
||||||
|
"""The constructor xbmcvfs stat class"""
|
||||||
|
self._stat = os.stat(path)
|
||||||
|
|
||||||
|
def st_mtime(self):
|
||||||
|
"""The xbmcvfs stat class st_mtime method"""
|
||||||
|
return self._stat.st_mtime
|
||||||
|
|
||||||
|
return stat(path)
|
||||||
|
|
||||||
|
|
||||||
|
def copy(src, dst):
|
||||||
|
"""A reimplementation of the xbmcvfs mkdir() function"""
|
||||||
|
return copyfile(src, dst) == dst
|
||||||
|
|
||||||
|
|
||||||
|
def delete(path):
|
||||||
|
"""A reimplementation of the xbmcvfs delete() function"""
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def exists(path):
|
||||||
|
"""A reimplementation of the xbmcvfs exists() function"""
|
||||||
|
return os.path.exists(path)
|
||||||
|
|
||||||
|
|
||||||
|
def listdir(path):
|
||||||
|
"""A reimplementation of the xbmcvfs listdir() function"""
|
||||||
|
files = []
|
||||||
|
dirs = []
|
||||||
|
if not exists(path):
|
||||||
|
return dirs, files
|
||||||
|
for filename in os.listdir(path):
|
||||||
|
fullname = os.path.join(path, filename)
|
||||||
|
if os.path.isfile(fullname):
|
||||||
|
files.append(filename)
|
||||||
|
if os.path.isdir(fullname):
|
||||||
|
dirs.append(filename)
|
||||||
|
return dirs, files
|
||||||
|
|
||||||
|
|
||||||
|
def mkdir(path):
|
||||||
|
"""A reimplementation of the xbmcvfs mkdir() function"""
|
||||||
|
return os.mkdir(path)
|
||||||
|
|
||||||
|
|
||||||
|
def mkdirs(path):
|
||||||
|
"""A reimplementation of the xbmcvfs mkdirs() function"""
|
||||||
|
return os.makedirs(path)
|
||||||
|
|
||||||
|
|
||||||
|
def rmdir(path):
|
||||||
|
"""A reimplementation of the xbmcvfs rmdir() function"""
|
||||||
|
return os.rmdir(path)
|
27
tox.ini
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
[tox]
|
||||||
|
envlist = py27,py36,py37,py38,flake8
|
||||||
|
skipsdist = True
|
||||||
|
skip_missing_interpreters = True
|
||||||
|
|
||||||
|
[testenv:flake8]
|
||||||
|
commands =
|
||||||
|
- {envbindir}/flake8
|
||||||
|
deps =
|
||||||
|
flake8
|
||||||
|
flake8-coding
|
||||||
|
flake8-future-import
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
builtins = func
|
||||||
|
max-line-length = 160
|
||||||
|
ignore = FI13,FI50,FI51,FI53,FI54,W503
|
||||||
|
require-code = True
|
||||||
|
min-version = 2.7
|
||||||
|
exclude = .git,.tox
|
||||||
|
|
||||||
|
[pytest]
|
||||||
|
filterwarnings = default
|
||||||
|
|
||||||
|
[pycodestyle]
|
||||||
|
max-line-length = 160
|
||||||
|
|