diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..a4a745839758f8d0c657628e1dbf061cfe4c3fbb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +* +!*.py +!lib +!plugins +!requirements.txt +!Docker_entrypoint.sh +!LICENSE \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..2eb7f0f7fd81e4cd9af522a097bbbc0f9de607f5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,22 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +lib/tvheadend/service/Windows/nssm.exe filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2 filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/fonts/material-icons/LDItaoyNOAY6Uewc665JcIzCKsKc_M9flwmJ_1.woff filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/fonts/subfont.woff2 filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/appletv/atv1-1080.png filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/blueradiance/bg.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/halloween/bg.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/holiday/bg.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/holiday/bg1.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/holiday/bg2.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/holiday/bg3.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/holiday/bg4.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/holiday/drawer.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/spring/bg.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/spring/bg1.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/spring/bg2.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/spring/bg3.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/spring/bg4.jpg filter=lfs diff=lfs merge=lfs -text +lib/web/htdocs/modules/themes/spring/bg5.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000000000000000000000000000000000..6c58b3a2b69b7e05d35b0b69d503d6621ea5c37f --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,20 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 14 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - bug + - documentation + - enhancement + - pinned + - security +# Label to use when marking an issue as stale +staleLabel: inactive +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000000000000000000000000000000000000..eb6d86cb2ad4ffe68f442a5961ea49240b22002d --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,62 @@ +name: Build & Push Cabernet + +on: + push: + # Publish `master` as Docker `latest` image. + branches: + - master + - dev + + # Publish `v1.2.3` tags as releases. + tags: + - v* + + # Run tests for any PRs. + pull_request: + +jobs: + # Push image to GitHub Packages. + # See also https://docs.docker.com/docker-hub/builds/ + docker-image: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} + type=ref,event=branch + type=semver,pattern={{version}} + type=ref,event=pr + + - name: Login to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4665dd0f7923ee9971bb22471874c0536756b4b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +debug.bat +locastChannels.m3u8 +redebug.bat +telly.exe +config_all.ini +config.ini +nbstreamreader.pyc +secret_notes.txt +SECRETS.txt +.vscode +service_uuid +TODO.md +*.pyc +config_future.ini +dev/ +data/ +cache/ +plugins_ext/ +*.ts +lib/tvheadend/development/ +lib/web/htdocs/temp/* +!lib/web/htdocs/temp/__init__.py +build/*/cabernet*.exe +ffmpeg/ +misc/ +#IntelliJ +.idea/ +cabernet.iml +test* +/lib/tvheadend/htdocs/Netbeans/nbproject/private/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..0ebdb91a2b7ffef3696a1a4ad32dff39c639a124 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,100 @@ +# CHANGELOG + +## FUTURE (may or may not be implemented): + - Reorganize config.ini file + - Start using pip for third-party plugins + - Rename `plex_accessible_ip`/`plex_accessible_port` to `advertise_ip`/`advertise_port`. Add `bind_ip`/`bind_port` options (https://github.com/tgorgdotcom/locast2plex/pull/98) + - Hopefully phase out FCC channel checking (when locast reports proper channel numbers) + - Install script for those not using docker + - Enable multiplatform Docker image + - Wrap HTTP requests around error handling that existed in do_tuner() previously + - Documentation added for Kodi, Emby/Jellyfin + - Implement proper logging + - Some kind of web based UI to modify config + - Look into pull requests suggestions for ip addressing + - A way to daemonize the script for those running outside of docker + +## 1.0.0 (unreleased) + - Most bugs squashed + +## 0.6.3 + - Add error handling for when a channel in the EPG exists that does not exist in the channel list + +## 0.6.2 + - Fix an issue where logins fail when passwords with a '%' are used + +## 0.6.1 + - Create dev branch, add contributing docs to mention dev branch + - rename master branch to main branch + - moved most SSDP messages to show when new config option `verbose` is set to true + - potential fix for error in getting fcc database + - fixed a bug in deleting stale cache EPG data + +## 0.6.0 + - Reorganized codebase for better modularity (@deathbybandaid) + - Included all DMA codes so we don't have to update whenever locast rolls out to a new market (@deathbybandaid) + - Added automatic pulling from FCC database (@deathbybandaid) + - Added m3u/xmltv playlist endpoint (for Emby, Kodi etc.) (with help from @deathbybandaid) + - Fixes to resolve legal complaints and... + - ...uses a new way to connect to Plex + - Some fixes for SSDP, may start working for applicable systems + - Added ability to disable SSDP using config.ini + - Fixes issue with query string in GET requests to the tuner + - Made sure all errors return nonzero + +## 0.5.3-hotfix + - Switch to Python 3 (Thanks @ratherDashing! & @deathbybandaid) + - Fix to resolve Locast auth issues + +## 0.5.3 + - Scripts are now fully linted (Thanks @deathbybandaid!) + - Updated Readme for spelling/clarity/credits (Thanks @tri-ler and @gogorichie!) + - Added ability to place config in /app/config folder for Kubernetes users (Thanks @dcd!) + - Added Detroit DMA support (Thanks @precision!) + - Fix tuner count comparison (Thanks @ratherDashing!) + - Refactored geolocation to mirror Locast methods (Thanks @FozzieBear!) + - Added new contributing document and added unreleased section in changelog + - Changed some var names for clarity. + - Fixed a bug where users running on bare command line/terminal could not set their ports. (Thanks @teconmoon) + - Removed some old stuff in Dockerfile that are confusing users + +## 0.5.2 + - Fixed a bug that prevented the success message from showing + +## 0.5.1 + - Added success message at the end to indicate successful running + - Fixed bug in docker-compose.yml + - Updated readme for better clarity + +## 0.5.0 + - Migrated environment settings to ini file -- should fix issues with special + characters in username/password, security concerns (thanks for the suggestion + @jcastilloalonso), as well as allowing to tweak internal settings without + resorting to modifying code. + - Added ffmpeg.exe for Windows users + - Merge fix to end ffmpeg zombie processes (thanks @FozzieBear!) + - Add MINNEAPOLIS-ST. PAUL (thanks @steventwheeler!) + - Fix for channel detection on subchannels ending in zero. + +## 0.4.2 + - Enabled Miami and West Palm Beach markets + - Fixed issue #10: renamed "docker-compose.env" to ".env" + +## 0.4.1 +- Added support for Tampa market +- Updated Changelog + +## 0.4.0 +- Fixed a bug in docker-compose.yml +- Fixed channels that only have one low resolution stream +- Overhauled channel detection and interpolation +- Reorganized api research notes to it's own folder + +## 0.3.1 +- Fix bugs in callsign detection +- More doc changes + +## 0.3.0 +- Created changelog! +- Remove telly dependency and doing the managing of streams and requests all by ourselves. +- Proper channel/subchannel applied to lineup diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..6e0dbb91f38aa740287dbb651887aad910355053 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +Hello all. For those of you who wish to contribute code, here's a few things to +take note of: + +## Project Goal +To allow Locast to be used in Plex (and possibly other media servers), for the +express purpose of serving those who want another way to access broadcast TV in +their service area. We will aim to be a good steward of the Locast terms of service. + +## Coding style/linting +For the most part we lint against PEP 8 and pyflakes, with exceptions +for rules E303, E501, W504, W605. I will be pretty lenient in taking pull requests, +so don't worry if you're not already familiar with linting. A good primer +on [linting in Python is here](https://realpython.com/python-code-quality/)' + +Aligning with the PEP 8, we use the following naming formats: + - Classnames: CapWords + - Methods/Properties: snake_case + - File names/Package names: snake_case + - Function/Variable Names: snake_case + + +## Versioning Scheme +We use the Semantic versioning scheme, with the following naming conventions for non-stable +releases: + - The 0.x series is beta + - Releases marked `-alpha` or `-beta` at the end of the version number. An additional number, + starting from 1, is added after this postfix to separate different version numbers. + Example: `2.0.0-beta3` + +## Road Map and New feature discussion +Plans for close-at-hand future releases are typically listed in CHANGELOG. New features +discussions can be made in GitHub as a new issue. I can also look into setting up other +channels of communications to discuss road maps, etc. as well if there is a need. \ No newline at end of file diff --git a/Docker_entrypoint.sh b/Docker_entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..d7fdea4c1572373deff8ef11eeddaa7cc17e5196 --- /dev/null +++ b/Docker_entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# Add local user +# Either use the USER_ID if passed in at runtime or +# fallback + +USER_ID=${PUID:-1000} +GROUP_ID=${PGID:-1000} +USERNAME=cabernet +echo "Starting with UID : $USER_ID" +addgroup -S -g $GROUP_ID $USERNAME +adduser -S -D -H -h /app -u $USER_ID -G $USERNAME $USERNAME + +blockUpdate="/app/Do_Not_Upgrade_from_WEBUI_on_Docker" + +oldKeyFile="/root/.cabernet/key.txt" +newKeyFile="/app/.cabernet/key.txt" + +if [ -f "$oldKeyFile" ]; then + +cat < DECREPTED Volume Option +Please update your volume for 'key.txt' to new location. +$newKeyFile +---------- +EOF + +cp "$oldKeyFile" "$newKeyFile" +fi + +# Set permissions +chown -R $USER_ID:$GROUP_ID /app + +[ ! -f "$blockUpdate" ] && touch "$blockUpdate" + +su-exec $USERNAME python3 /app/tvh_main.py "$@" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a25b05444370f0a2e2018f80de511704e1bfc6b0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-alpine +WORKDIR /app +COPY . . +RUN apk add --no-cache --update bash tzdata ffmpeg curl su-exec && \ + apk add --no-cache --virtual builddeps gcc musl-dev python3-dev libffi-dev openssl-dev cargo && \ + pip3 install -r requirements.txt --no-cache-dir && \ + apk del builddeps && \ + touch /app/is_container && \ + mv Docker_entrypoint.sh /usr/local/bin && \ + rm -rf /tmp/* $HOME/.cache $HOME/.cargo + +VOLUME /app/data /app/plugins_ext /app/.cabernet +EXPOSE 6077 5004 +ENTRYPOINT ["Docker_entrypoint.sh"] diff --git a/Dockerfile_tvh_crypt.alpine b/Dockerfile_tvh_crypt.alpine new file mode 100644 index 0000000000000000000000000000000000000000..db3f83df6a14e34ba79991f8e6a4fdbc5b0eccce --- /dev/null +++ b/Dockerfile_tvh_crypt.alpine @@ -0,0 +1,16 @@ +FROM python:3.12-alpine +#RUN apk add --no-cache --update bash tzdata ffmpeg py3-cryptography py-requests && \ +RUN apk add --no-cache --update bash tzdata ffmpeg curl && \ + apk add --no-cache --virtual builddeps gcc musl-dev python3-dev libffi-dev openssl-dev cargo && \ + pip3 install requests && \ + pip3 install streamlink && \ + pip3 install cryptography --no-binary=cryptography && \ + apk del builddeps + +COPY requirements.txt /app/requirements.txt + +COPY *.py /app/ +COPY lib/ /app/lib/ +COPY plugins /app/plugins +RUN touch /app/is_container +ENTRYPOINT ["python3", "/app/tvh_main.py"] diff --git a/Dockerfile_tvh_crypt.slim-buster b/Dockerfile_tvh_crypt.slim-buster new file mode 100644 index 0000000000000000000000000000000000000000..3926afa3982adc8efde81151d4f0188f64826d53 --- /dev/null +++ b/Dockerfile_tvh_crypt.slim-buster @@ -0,0 +1,32 @@ +ARG BASE_IMAGE=python:3.12-slim-buster +FROM $BASE_IMAGE as base +WORKDIR /app +ENV PYTHONPATH "${PYTHONPATH}:/app" + +## +# Install any runtime dependencies here +ENV RUNTIME_DEPENDENCIES="ffmpeg curl" + +RUN apt-get update \ + && apt-get install -y $RUNTIME_DEPENDENCIES \ +&& rm -rf /var/lib/apt/lists/* + +#ENV BUILD_DEPENDENCIES="build-essential" +ENV BUILD_DEPENDENCIES="" + +COPY requirements.txt /app/requirements.txt + +# Install any build dependencies here +RUN apt-get update \ + && pip install --upgrade pip \ + && apt-get install -y $BUILD_DEPENDENCIES \ + && pip install --no-cache-dir -r requirements.txt \ +&& apt-get remove -y $BUILD_DEPENDENCIES \ +&& apt-get auto-remove -y \ +&& rm -rf /var/lib/apt/lists/* + +COPY *.py /app/ +COPY lib/ /app/lib/ +COPY plugins /app/plugins +RUN touch /app/is_container +ENTRYPOINT ["python3", "/app/tvh_main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..e1dcfc0b3328b37c38b2e6683f7a7610c510c03b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ROCKY4546 (https://github.com/rocky4546) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1abb9c1564dcb46be85d2b545bad458818054635..6e240eda1f5f5d7fe0bb87b8ba8064710908a801 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,147 @@ ---- -title: Cab -emoji: 🐨 -colorFrom: gray -colorTo: purple -sdk: docker -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +## NOTICE: +By default this app doesn't provide any video sources, only the plugins access the providers streams for personal use. + +## Installation +### 1. Requirements +- Python 3.10.12+ +- python cryptography module +- python httpx[http2] module +- (optional) streamlink module +- ffmpeg and ffprobe + +### 2. Installation +- Download source +- Unzip source in the installation folder +- Launch the app by running the command "python3 tvh_main.py". This should create a data folder and a config.ini inside that folder +- Bring up browser and go to http://ip address:6077/ +- From Plugins, install PlutoTV plugin +- Restart Cabernet twice from Scheduled Tasks > Applications > Restart +- Go to settings and make changes you want. + - Logging: Change log level from warning to info if needed +- From XML/JSON Links try some of the links + +### 3. Services +- MS Windows + - Services for MS Windows is auto-created using the installer provided for each release. +- Unix/Linux + - Services for CoreELEC and Debian/Ubuntu are found here. Follow the instructions found in the files. + - https://github.com/cabernetwork/cabernet/tree/master/lib/tvheadend/service + +### 4. Docker +You can either use docker-compose or the docker cli. + +| Architecture | Available | +|:----:|:----:| +| X86-64 | ✅ | +| arm64 | ✅ | +| armhf | ❌ | + +**NOTES:** +- Volume for ```/app/.cabernet``` must be provided before enabling encryption. +- armhf not available due to python cryptography only supports 64bit systems. +[Cryptography supported platforms](https://cryptography.io/en/latest/installation/#supported-platforms) + +#### a. Using docker-compose +To install Cabernet: +``` +1. Grab the cabernet source and unpack into a folder +2. Edit docker-compose.yml and set the volume folder locations +3. docker-compose pull cabernet +4. docker-compose up -d cabernet +``` + +#### b. docker cli +``` +docker run -d \ + --name=cabernet \ + -e PUID=1000 `#optional` \ + -e PGID=1000 `#optional` \ + -e TZ=Etc/UTC `#optional` \ + -p 6077:6077 \ + -p 5004:5004 \ + -v /path/to/cabernet/data:/app/data `#optional` \ + -v /path/to/plugins_ext:/app/plugins_ext `#optional` \ + -v /path/to/cabernet/secrets:/app/.cabernet `#optional` \ + --restart unless-stopped \ + ghcr.io/cabernetwork/cabernet:latest +``` + +##### Parameters +| Parameter | Function | +| :----: | :----: | +| -p 6077 | Cabernet WebUI | +| -p 5004 | Cabernet stream port | +| -e PUID=1000 | for UserID | +| -e PGID=1000 | for GroupID | +| -e TZ=Etc/UTC | specify a timezone to use, see this [list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List).| +| -v /app/data | Where Cabernet should store its database and config. | +| -v /app/plugins_ext | External Plugins | +| -v /app/.cabernet | Where encryption key is stored | + +#### c. Other Docker Info +Cabernet configuration setting Clients > Web Sites > plex_docker_ip should be set to your computers IP address and not the internal IP inside the docker container. This will allow the channels.m3u file to have the correct IP address for streaming. + + +#### d. Updating Info +**Via Docker Compose:** + +- Update the image: +``` +docker-compose rm --stop -f cabernet +docker-compose pull cabernet +docker-compose up -d cabernet +``` + +**Via Docker Run:** + +- Update the image: +```docker pull ghcr.io/cabernetwork/cabernet:latest``` + +- Stop the running container: +```docker stop cabernet``` + +- Delete the container: +```docker rm cabernet``` + +- You can also remove the old dangling images: +```docker image prune``` + +#### e. Via Watchtower auto-updater +``` +docker run --rm \ + -v /var/run/docker.sock:/var/run/docker.sock \ + containrrr/watchtower \ + --run-once cabernet +``` + +- For regulary updates follow Watchtower instructions +https://containrrr.dev/watchtower/ + + +### 5. Default Ports +- 6007 Web UI +- 5004 Stream port +- 1900 SSDP (if enabled) +- 65001 HDHomeRun (if enabled) + +### 6. Notes +- URL used can include plugin and instance levels to filter down to a specific set of data + - http://ip address:6077/channels.m3u + - http://ip address:6077/pLuToTv/channels.m3u + - http://ip address:6077/PlutoTV/Default/channels.m3u +- config.ini group tag requirements when creating an instance + - All lower case + - Underscore is a key character in section tags and separates the plugin name from the instance name + - Use a single word if possible for the instance name + - Do not change the instance name unless you go into data management and remove the instance first. + - [plutotv_mychannels] + +### 7. Guides +Beginners Setup: https://github.com/cabernetwork/cabernet/issues/78 + +### 8. Forum +Latest Forum: https://tvheadend.org/d/7455-daddylive-plutotv-xumo-m3u-xmltv-samsungtv-plex-tvguide-interfaces-appliance + +Archived Legacy Forum: https://web.archive.org/web/20221015111333/https://tvheadend.org/boards/5/topics/43052 + +Enjoy diff --git a/TVHEADEND.md b/TVHEADEND.md new file mode 100644 index 0000000000000000000000000000000000000000..0cafe6058d2b3229d51fd52a230df1f529f13026 --- /dev/null +++ b/TVHEADEND.md @@ -0,0 +1,32 @@ +## Cabernet +You have found the Cabernet README. +To allow for easy insertion of the locast2plex baseline, most of the top level files are directly from locast2plex including the main README.md. + +Additional information is provided on the [Wiki](https://cabernetwork.github.io/) pages. + +### Purpose: +This application has two primary purposes along with many minor updates. + +The first purpose is to provide a TVHeadend interface which allows for automatic population of the mux, services and EPG with a standard naming convention, so that, the channels will map the EPG to the services correctly and provide automated updates as needed. + +Second, is to update the application to support the free locast account version with TVHeadend. + +### Relationship to locast2plex: +The application is setup to use most of the locast2plex software. As fixes are addressed in that project, those changes are folded into this application. Key areas, such as, managing the cached files and station lists, logging into locast, using Docker and supporting SSDP are all maintained by locast2plex. + +### Use of ffmpeg software +There are many locast interface applications popping up everywhere; many try to not use ffmpeg executables. Although python could do what ffmpeg does, it would be slower and use more CPU. Since it should be available on Linux systems and we have addressed it on Windows, use of this executable should not be an issue. One solution for subscription-based accounts is to detect if ffmpeg is not present and then to use a python-based method for streaming the data to the end device. + +### Important Legal Notice: +As stated by locast2plex. Do not use this product in an illegal way to obtain channels outside of your general market area. To have locast continue broadcasting and to have this product available is to use it legally. + +### Developers Notes: +Pull requests are welcome. If you cannot submit a PR, then write an issue/enhancement with as much detail as possible and it will be reviewed. + +### Submitting Issues/Enhancements +All issues will be triaged and labeled. Before writing an issue, check the issue list for duplicates to see if there are any fixes for similar issues. If you are not sure, write a new issue. The issue may be associated with locast2plex software. In this case, it will be determined whether we can make the fix or have you follow up with locast2plex. Initially, add as much detail as possible to reproduce the issue. Logs, hardware and OS info is not important up front, but may be requested based on the issue. + +When posting logs, you can turn the locast2plex logging off by setting the [main][quiet_print] setting to True. This should remove most of the sensitive information. Always review any data you post for sensitive info before you post. + +## Credits +Thank you to all the people at [locast2plex](https://github.com/tgorgdotcom/locast2plex) for creating the initial product. diff --git a/build/WINDOWS/Base64.nsh b/build/WINDOWS/Base64.nsh new file mode 100644 index 0000000000000000000000000000000000000000..9d1f855036ef32e9a4fc51230dff7421a7251c51 --- /dev/null +++ b/build/WINDOWS/Base64.nsh @@ -0,0 +1,292 @@ +!ifndef BASE64_NSH +!define BASE64_NSH + +!define BASE64_ENCODINGTABLE "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +!define BASE64_ENCODINGTABLEURL "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + +!define BASE64_PADDING "=" + +VAR OCTETVALUE +VAR BASE64TEMP + +!define Base64_Encode "!insertmacro Base64_Encode" +!define Base64_URLEncode "!insertmacro Base64_URLEncode" + +!macro Base64_Encode _cleartext + push $R0 + push $R1 + push $R2 + push $0 + push $1 + push $2 + push $3 + push $4 + push $5 + push $6 + push $7 + push `${_cleartext}` + push `${BASE64_ENCODINGTABLE}` + Call Base64_Encode + Pop $BASE64TEMP + Pop $7 + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 + pop $R2 + pop $R1 + pop $R0 + Push $BASE64TEMP +!macroend + +!macro Base64_URLEncode _cleartext + push $R0 + push $R1 + push $R2 + push $0 + push $1 + push $2 + push $3 + push $4 + push $5 + push $6 + push $7 + push `${_cleartext}` + push `${BASE64_ENCODINGTABLEURL}` + Call Base64_Encode + Pop $BASE64TEMP + Pop $7 + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 + pop $R2 + pop $R1 + pop $R0 + Push $BASE64TEMP +!macroend + +Function Base64_Encode + pop $R2 ; Encoding table + pop $R0 ; Clear Text + StrCpy "$R1" "" # The result + + StrLen $1 "$R0" + StrCpy $0 0 + + ${WHILE} $0 < $1 + # Copy 3 characters, and for each character push their value. + StrCpy $OCTETVALUE 0 + + StrCpy $5 $0 + StrCpy $4 "$R0" 1 $5 + ${CharToASCII} $4 "$4" + + IntOp $OCTETVALUE $4 << 16 + + IntOp $5 $5 + 1 + ${IF} $5 < $1 + StrCpy $4 "$R0" 1 $5 + ${CharToASCII} $4 "$4" + + IntOp $4 $4 << 8 + IntOp $OCTETVALUE $OCTETVALUE + $4 + + IntOp $5 $5 + 1 + ${IF} $5 < $1 + StrCpy $4 "$R0" 1 $5 + ${CharToASCII} $4 "$4" + + IntOp $OCTETVALUE $OCTETVALUE + $4 + ${ENDIF} + ${ENDIF} + + # Now take the 4 indexes from the encoding table, based on 6bits each of the octet's value. + IntOp $4 $OCTETVALUE >> 18 + IntOp $4 $4 & 63 + StrCpy $5 "$R2" 1 $4 + StrCpy $R1 "$R1$5" + + IntOp $4 $OCTETVALUE >> 12 + IntOp $4 $4 & 63 + StrCpy $5 "$R2" 1 $4 + StrCpy $R1 "$R1$5" + + StrCpy $6 $0 + StrCpy $7 2 + + IntOp $6 $6 + 1 + ${IF} $6 < $1 + IntOp $4 $OCTETVALUE >> 6 + IntOp $4 $4 & 63 + StrCpy $5 "$R2" 1 $4 + StrCpy $R1 "$R1$5" + IntOp $7 $7 - 1 + ${ENDIF} + + IntOp $6 $6 + 1 + ${IF} $6 < $1 + IntOp $4 $OCTETVALUE & 63 + StrCpy $5 "$R2" 1 $4 + StrCpy $R1 "$R1$5" + IntOp $7 $7 - 1 + ${ENDIF} + + # If there is any padding required, we now write that here. + ${IF} $7 > 0 + ${WHILE} $7 > 0 + StrCpy $R1 "$R1${BASE64_PADDING}" + IntOp $7 $7 - 1 + ${ENDWHILE} + ${ENDIF} + + IntOp $0 $0 + 3 + ${ENDWHILE} + + Push "$R1" +FunctionEnd + + +!define Base64_Decode "!insertmacro Base64_Decode" +!define Base64_URLDecode "!insertmacro Base64_URLDecode" + +!macro Base64_Decode _encodedtext + push `${_encodedtext}` + push `${BASE64_ENCODINGTABLE}` + Call Base64_Decode +!macroend + +!macro Base64_URLDecode _encodedtext + push `${_encodedtext}` + push `${BASE64_ENCODINGTABLEURL}` + Call Base64_Decode +!macroend + +Function base64_Decode + ; Stack: strBase64table strEncoded + Push $9 ; Stack: $9 strBase64table strEncoded ; $9 = strDecoded + Exch 2 ; Stack: strEncoded strBase64table $9 + Exch ; Stack: strBase64table strEncoded $9 + Exch $0 ; Stack: $0 strEncoded $9 ; $0 = strBase64table + Exch ; Stack: strEncoded $0 $9 + Exch $1 ; Stack: $1 $0 $9 ; $1 = strEncoded + + Push $2 ; strBase64table.length + Push $3 ; strEncoded.length + Push $4 ; strBase64table.counter + Push $5 ; strEncoded.counter + Push $6 ; strBase64table.char + Push $7 ; strEncoded.char + + Push $R0 ; 6bit-group.counter + Push $R1 ; 6bit-group.a + Push $R2 ; 6bit-group.b + Push $R3 ; 6bit-group.c + Push $R4 ; 6bit-group.d + + Push $R5 ; bit-group.tempVar.a + Push $R6 ; bit-group.tempVar.b + + Push $R7 ; 8bit-group.A + Push $R8 ; 8bit-group.B + Push $R9 ; 8bit-group.C + + StrCpy $9 "" ; Result string + + StrLen $2 "$0" ; Get the length of the base64 table into $2 + StrLen $3 "$1" ; Get the length of the encoded text into $3 + IntOp $3 $3 - 1 ; Subtract one as the StrCpy offset is zero-based + + StrCpy $R0 4 ; Initialize the 6bit-group.counter + + ${ForEach} $5 0 $3 + 1 ; Loop over the encoded string + StrCpy $7 $1 1 $5 ; Grab the character at the loop counter's index + + ${If} $7 == "${BASE64_PADDING}" ; If it's the padding char + Push 0 ; Push value 0 (no impact on decoded string) + ${Else} ; Otherwise + ${ForEach} $4 0 $2 + 1 ; Loop over the base64 lookup table + StrCpy $6 $0 1 $4 ; Grab the character at this loop counter's index + ${If} $6 S== $7 ; If that character matches the encoded string character + ${ExitFor} ; Exit this loop early + ${EndIf} + ${Next} + Push $4 ; Push the lookup's index to the stack + ${EndIf} + + IntOp $R0 $R0 - 1 ; Decrease the 6bit-group counter + + ${If} $R0 = 0 ; If that counter reaches zero + ; Pop the index values off the stack to variables + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R1 + + ; The way the base64 decoding works is like this... + ; Normal ASCII has 8 bits, base64 has 6 bits. + ; Those 8 bits need to be presented as 6 bits somehow + ; Turns out you can easily do that by taking their common multiple: 24 + ; This results in 3 8bit characters per each 4 6bit characters: + ; AAAAAAAA BBBBBBBB CCCCCCCC + ; aaaaaabb bbbbcccc ccdddddd + + ; So to go back to AAAAAAAA, you need: + ; aaaaaa shifted two bits to the left + ; the two left-most bits of bbbbbb, + ; which you can do by shifting it four bits to the right + IntOp $R5 $R1 << 2 + IntOp $R6 $R2 >> 4 + IntOp $R5 $R5 | $R6 + IntFmt $R7 "%c" $R5 ; IntFmt turns the resulting 8bit value to a character + + ; For BBBBBBBB, you need: + ; the four least significant bits of bbbbbb + ; which you can get by binary OR'ing with 2^4-1 = 15 + ; the four most significant bits of cccccc + ; which you can get by just shifting it two bits to the right + IntOp $R5 $R2 & 15 + InTop $R5 $R5 << 4 + IntOp $R6 $R3 >> 2 + IntOp $R5 $R5 | $R6 + IntFmt $R8 "%c" $R5 + + ; For CCCCCCCC, the procedure is entirely similar. + IntOp $R5 $R3 & 3 + IntOp $R5 $R5 << 6 + IntOp $R5 $R5 | $R4 + IntFmt $R9 "%c" $R5 + + StrCpy $9 "$9$R7$R8$R9" ; Tack it all onto the result + StrCpy $R0 4 ; Reset the 6bit-group counter + ${EndIf} + ${Next} + + ; Done. Now let's restore the user's variables + Pop $R9 + Pop $R8 + Pop $R7 + Pop $R6 + Pop $R5 + Pop $R4 + Pop $R3 + Pop $R2 + Pop $R1 + Pop $R0 + Pop $7 + Pop $6 + Pop $5 + Pop $4 + Pop $3 + Pop $2 + Pop $1 + Pop $0 + Exch $9 ; Stack: strDecoded +FunctionEnd +!endif ;BASE64_NSH \ No newline at end of file diff --git a/build/WINDOWS/CharToASCII.nsh b/build/WINDOWS/CharToASCII.nsh new file mode 100644 index 0000000000000000000000000000000000000000..27dcce79df045abf9ed31f2a298ce5801565bc42 --- /dev/null +++ b/build/WINDOWS/CharToASCII.nsh @@ -0,0 +1,28 @@ +!define CharToASCII "!insertmacro CharToASCII" + +!macro CharToASCII AsciiCode Character + Push "${Character}" + Call CharToASCII + Pop "${AsciiCode}" +!macroend + +Function CharToASCII + Exch $0 ; given character + Push $1 ; current character + Push $2 ; current Ascii Code + + StrCpy $2 1 ; right from start +Loop: + IntFmt $1 %c $2 ; Get character from current ASCII code + ${If} $1 S== $0 ; case sensitive string comparison + StrCpy $0 $2 + Goto Done + ${EndIf} + IntOp $2 $2 + 1 + StrCmp $2 255 0 Loop ; ascii from 1 to 255 + StrCpy $0 0 ; ASCII code wasn't found -> return 0 +Done: + Pop $2 + Pop $1 + Exch $0 +FunctionEnd \ No newline at end of file diff --git a/build/WINDOWS/Plugins/SimpleSC/License.txt b/build/WINDOWS/Plugins/SimpleSC/License.txt new file mode 100644 index 0000000000000000000000000000000000000000..71e0f2a00a206568a4a107edf9b278934fce5699 --- /dev/null +++ b/build/WINDOWS/Plugins/SimpleSC/License.txt @@ -0,0 +1,27 @@ +SimpleSC - NSIS Service Control Plugin - License Agreement + +This plugin is subject to the Mozilla Public License Version 1.1 (the "License"); +You may not use this plugin except in compliance with the License. You may +obtain a copy of the License at http://www.mozilla.org/MPL. + +Alternatively, you may redistribute this library, use and/or modify it +under the terms of the GNU Lesser General Public License as published +by the Free Software Foundation; either version 2.1 of the License, +or (at your option) any later version. You may obtain a copy +of the LGPL at www.gnu.org/copyleft. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the License. + +Copyright + +Portions of this software are Copyright (C) 2001 - Peter Windridge, 2003 by +Bernhard Mayer, Fixed and formatted by Brett Dever http://editor.nfscheats.com/ + +The original code is ServiceControl.pas, released April 16, 2007. + +The initial developer of the original code is Rainer Budde (http://www.speed-soft.de). + +SimpleSC - NSIS Service Control Plugin is written, published and maintaned by +Rainer Budde (rainer@speed-soft.de). \ No newline at end of file diff --git a/build/WINDOWS/Plugins/SimpleSC/Readme.txt b/build/WINDOWS/Plugins/SimpleSC/Readme.txt new file mode 100644 index 0000000000000000000000000000000000000000..1854869ae626f3386385d327010ea253742641e6 --- /dev/null +++ b/build/WINDOWS/Plugins/SimpleSC/Readme.txt @@ -0,0 +1,335 @@ +NSIS Simple Service Plugin + +This plugin contains basic service functions like start, stop the +service or checking the service status. It also contains advanced +service functions for example setting the service description, changed +the logon account, granting or removing the service logon privilege. + + + + +== Short Reference == + + +SimpleSC::InstallService [name_of_service] [display_name] [service_type] [start_type] [binary_path] [dependencies] [account] [password] +SimpleSC::RemoveService [name_of_service] + +SimpleSC::StartService [name_of_service] [arguments] [timeout] +SimpleSC::StopService [name_of_service] [wait_for_file_release] [timeout] +SimpleSC::PauseService [name_of_service] [timeout] +SimpleSC::ContinueService [name_of_service] [timeout] +SimpleSC::RestartService [name_of_service] [arguments] [timeout] +SimpleSC::ExistsService [name_of_service] + +SimpleSC::GetServiceDisplayName [name_of_service] +SimpleSC::GetServiceName [display_name] +SimpleSC::GetServiceStatus [name_of_service] +SimpleSC::GetServiceDescription [name_of_service] +SimpleSC::GetServiceStartType [name_of_service] +SimpleSC::GetServiceBinaryPath [name_of_service] +SimpleSC::GetServiceLogon [name_of_service] +SimpleSC::GetServiceFailure [name_of_service] +SimpleSC::GetServiceFailureFlag [name_of_service] +SimpleSC::GetServiceDelayedAutoStartInfo [name_of_service] + +SimpleSC::SetServiceDescription [name_of_service] [service_description] +SimpleSC::SetServiceStartType [name_of_service] [start_type] +SimpleSC::SetServiceBinaryPath [name_of_service] [binary_path] +SimpleSC::SetServiceLogon [name_of_service] [account] [password] +SimpleSC::SetServiceFailure [name_of_service] [reset_period] [reboot_message] [command] [action_type_1] [action_delay_1] [action_type_2] [action_delay_2] [action_type_3] [action_delay_3] +SimpleSC::SetServiceFailureFlag [name_of_service] [failure_actions_on_non_crash_failures] +SimpleSC::SetServiceDelayedAutoStartInfo [name_of_service] [delayed_autostart] + +SimpleSC::GrantServiceLogonPrivilege [account] +SimpleSC::RemoveServiceLogonPrivilege [account] + +SimpleSC::ServiceIsPaused [name_of_service] +SimpleSC::ServiceIsRunning [name_of_service] +SimpleSC::ServiceIsStopped [name_of_service] + +SimpleSC::GetErrorMessage [error_code] + + +Parameters: + +name_of_service - The name of the service used for Start/Stop commands and all further commands + +display_name - The name as shown in the service control manager applet in system control + +service_type - One of the following codes + 1 - SERVICE_KERNEL_DRIVER - Driver service. + 2 - SERVICE_FILE_SYSTEM_DRIVER - File system driver service. + 16 - SERVICE_WIN32_OWN_PROCESS - Service that runs in its own process. (Should be used in most cases) + 32 - SERVICE_WIN32_SHARE_PROCESS - Service that shares a process with one or more other services. + 256 - SERVICE_INTERACTIVE_PROCESS - The service can interact with the desktop. + Note: If you specify either SERVICE_WIN32_OWN_PROCESS or SERVICE_WIN32_SHARE_PROCESS, + and the service is running in the context of the LocalSystem account, + you can also specify this value. + Example: SERVICE_WIN32_OWN_PROCESS or SERVICE_INTERACTIVE_PROCESS - (16 or 256) = 272 + Note: Services cannot directly interact with a user as of Windows Vista. + Therefore, this technique should not be used in new code. + See for more information: http://msdn2.microsoft.com/en-us/library/ms683502(VS.85).aspx + +start_type - one of the following codes + 0 - SERVICE_BOOT_START - Driver boot stage start + 1 - SERVICE_SYSTEM_START - Driver scm stage start + 2 - SERVICE_AUTO_START - Service auto start (Should be used in most cases) + 3 - SERVICE_DEMAND_START - Driver/service manual start + 4 - SERVICE_DISABLED - Driver/service disabled + +service_status - one of the following codes + 1 - SERVICE_STOPPED + 2 - SERVICE_START_PENDING + 3 - SERVICE_STOP_PENDING + 4 - SERVICE_RUNNING + 5 - SERVICE_CONTINUE_PENDING + 6 - SERVICE_PAUSE_PENDING + 7 - SERVICE_PAUSED + +binary_path - The path to the binary including all necessary parameters + +dependencies - Needed services, controls which services have to be started before this one; use the forward slash "/" to add more more than one service + +account - The username/account which should be used + +password - Password of the aforementioned account to be able to logon as a service + Note: If you do not specify account/password, the local system account will be used to run the service + +arguments - Arguments passed to the service main function. + Note: Driver services do not receive these arguments. + +reset_period - The time after which to reset the failure count to zero if there are no failures, in seconds. Specify 0 (INFINITE) to indicate that this value should never be reset + +reboot_message - The message to be broadcast to server users before rebooting + +command - The command line of the process to execute in response to the SC_ACTION_RUN_COMMAND service controller action. This process runs under the same account as the service + +timeout - Timeout in seconds of the function + +action_type_x - one of the following codes for the action to be performed + 0 - SC_ACTION_NONE - No action + 1 - SC_ACTION_RESTART - Restart the service + 2 - SC_ACTION_REBOOT - Reboot the computer (Note: The service user must have the SE_SHUTDOWN_NAME privilege) + 3 - SC_ACTION_RUN_COMMAND - Run a command + +action_delay_x - The time to wait before performing the specified action, in milliseconds + +failure_actions_on_non_crash_failures - This setting determines when failure actions are to be executed + 0 - The failure actions executed only if the service terminates without reporting a status of SERVICE_STOPPED + 1 - The failure actions executed if the status of a service is SERVICE_STOPPED but the exit code of the service is not 0 + +delayed_autostart - The delayed auto-start setting of an auto-start service + 0 - The service will be started during system boot. + 1 - The service will be started after other auto-start services are started plus a short delay + +error_code - Error code of a function + +service_description - The description as shown in the service control manager applet in system control + +wait_for_file_release - Wait for file release after the service is stopped. This is useful if the binary file will be overwritten after stopping the service. + 0 - NO_WAIT - No wait for file release + 1 - WAIT - Wait for file release + Note: If SERVICE_WIN32_OWN_PROCESS is used this option should be set to WAIT. + If SERVICE_WIN32_SHARE_PROCESS is used this option should only be set to WAIT if the last service + in the process is stopped. + + + + +== The Sample Script == + + +; Install a service - ServiceType own process - StartType automatic - NoDependencies - Logon as System Account + SimpleSC::InstallService "MyService" "My Service Display Name" "16" "2" "C:\MyPath\MyService.exe" "" "" "" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Install a service - ServiceType interact with desktop - StartType automatic - Dependencies on "Windows Time Service" (w32time) and "WWW Publishing Service" (w3svc) - Logon as System Account + SimpleSC::InstallService "MyService" "My Service Display Name" "272" "2" "C:\MyPath\MyService.exe" "w32time/w3svc" "" "" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Remove a service + SimpleSC::RemoveService "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Start a service + SimpleSC::StartService "MyService" "" 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Start a service with two arguments "/param1=true" "/param2=1" + SimpleSC::StartService "MyService" "/param1=true /param2=1" 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Start a service with two arguments "-p param1" "-param2" + SimpleSC::StartService "MyService" '"-p param1" -param2' 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Stop a service and waits for file release + SimpleSC::StopService "MyService" 1 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Stops two services and waits for file release after the last service is stopped + SimpleSC::StopService "MyService1" 0 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + SimpleSC::StopService "MyService2" 1 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Pause a service + SimpleSC::PauseService "MyService" 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Continue a service + SimpleSC::ContinueService "MyService" 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Restart a service + SimpleSC::RestartService "MyService" "" 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Restart a service with two arguments "/param1=true" "/param2=1" + SimpleSC::RestartService "MyService" "/param1=true /param2=1" 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Start a service with two arguments "-p param1" "-param2" + SimpleSC::RestartService "MyService" '"-p param1" -param2' 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Check if the service exists + SimpleSC::ExistsService "MyService" + Pop $0 ; returns an errorcode if the service doesnt exists (<>0)/service exists (0) + +; Get the displayname of a service + SimpleSC::GetServiceDisplayName "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the displayname of the service + +; Get the servicename of a service by the displayname + SimpleSC::GetServiceName "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the servicename of the service + +; Get the current status of a service + SimpleSC::GetServiceStatus "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; return the status of the service (See "service_status" in the parameters) + +; Get the description of a service + SimpleSC::GetServiceDescription "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the description of the service + +; Get the start type of the service + SimpleSC::GetServiceStartType "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the start type of the service (see "start_type" in the parameters) + +; Get the binary path of a service + SimpleSC::GetServiceBinaryPath "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the binary path of the service + +; Get the logon user of the service + SimpleSC::GetServiceLogon "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the logon username of the service + +; Get the failure configuration of a service + SimpleSC::GetServiceFailure "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the reset period + Pop $2 ; returns the reboot message + Pop $3 ; returns the command + Pop $4 ; returns the first action (See "action_type_x" in the parameters) + Pop $5 ; returns the first action delay + Pop $6 ; returns the second action (See "action_type_x" in the parameters) + Pop $7 ; returns the second action delay + Pop $8 ; returns the third action (See "action_type_x" in the parameters) + Pop $9 ; returns the third action delay + +; Get the failure flag configuration of a service + SimpleSC::GetServiceFailureFlag "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the service flag + +; Get the delayed auto-start configuration of a service + SimpleSC::GetServiceDelayedAutoStartInfo "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns the delayed auto-start configuration + +; Set the description of a service + SimpleSC::SetServiceDescription "MyService" "Sample Description" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Set the starttype to automatic of a service + SimpleSC::SetServiceStartType "MyService" "2" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Sets the service binary path + SimpleSC::SetServiceBinaryPath "MyService" "C:\MySoftware\MyService.exe" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Sets the service logon to a user and grant the user the "SeServiceLogonPrivilege" + SimpleSC::SetServiceLogon "MyService" "MyServiceUser" "MyServiceUserPassword" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + IntCmp $0 0 +1 Done Done ; If successful grant the service logon privilege to "MyServiceUser" + ; Note: Every serviceuser must have the ServiceLogonPrivilege to start the service + SimpleSC::GrantServiceLogonPrivilege "MyServiceUser" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Done: + +; Sets the service failure configuration - First action: Restart the service after one minute - Second action: Reboot the computer after five minutes + SimpleSC::SetServiceFailure "MyService" "0" "" "" "1" "60000" "2" "300000" "0" "0" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Sets the failure flag configuration of a service + SimpleSC::SetServiceFailureFlag "MyService" "1" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Sets the delayed auto-start configuration of a service + SimpleSC::SetServiceDelayedAutoStartInfo "MyService" "1" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Remove the "SeServiceLogonPrivilege" from a user + SimpleSC::RemoveServiceLogonPrivilege "MyServiceUser" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + +; Check if the service is paused + SimpleSC::ServiceIsPaused "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns 1 (service is paused) - returns 0 (service is not paused) + +; Check if the service is running + SimpleSC::ServiceIsRunning "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns 1 (service is running) - returns 0 (service is not running) + +; Check if the service is stopped + SimpleSC::ServiceIsStopped "MyService" + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + Pop $1 ; returns 1 (service is stopped) - returns 0 (service is not stopped) + +; Show the error message if a function fails + SimpleSC::StopService "MyService" 1 30 + Pop $0 ; returns an errorcode (<>0) otherwise success (0) + IntCmp $0 0 Done +1 +1 + Push $0 + SimpleSC::GetErrorMessage + Pop $0 + MessageBox MB_OK|MB_ICONSTOP "Stopping fails - Reason: $0" + Done: + + + + +== Important Notes == +- The function "SetServiceLogon" only works if the servicetype is + "SERVICE_WIN32_OWN_PROCESS". +- The functions "GetServiceDescription", "SetServiceDescription", "GetServiceFailure" and + "SetServiceFailure" are only available on systems higher than Windows NT. +- The function "GetServiceFailureFlag", "SetServiceFailureFlag", "GetServiceDelayedAutoStartInfo" and + "SetServiceDelayedAutoStartInfo" are only available on systems higher than Windows 2003. +- If you change the logon of an service to a new user you have to grant him + the Service Logon Privilege. Otherwise the service cannot be started by + the user you have assigned. +- The functions StartService, StopService, PauseService and ContinueService uses + a timeout of 30 seconds. This means the function must be executed within 30 seconds, + otherwise the functions will return an error. \ No newline at end of file diff --git a/build/WINDOWS/Plugins/SimpleSC/SimpleSC.dll b/build/WINDOWS/Plugins/SimpleSC/SimpleSC.dll new file mode 100644 index 0000000000000000000000000000000000000000..d61a12b662fdf9385d5454e5c012f63dead03272 Binary files /dev/null and b/build/WINDOWS/Plugins/SimpleSC/SimpleSC.dll differ diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/afxres.h b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/afxres.h new file mode 100644 index 0000000000000000000000000000000000000000..3fbfaa46374fcb3405b960ad35f8bba62a76cdf6 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/afxres.h @@ -0,0 +1,2 @@ +#include +#define IDC_STATIC (-1) diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/api.h b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/api.h new file mode 100644 index 0000000000000000000000000000000000000000..e871de3d40d08d2fb62e2fd3a980232f38500297 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/api.h @@ -0,0 +1,83 @@ +/* + * apih + * + * This file is a part of NSIS. + * + * Copyright (C) 1999-2013 Nullsoft and Contributors + * + * Licensed under the zlib/libpng license (the "License"); + * you may not use this file except in compliance with the License. + * + * Licence details can be found in the file COPYING. + * + * This software is provided 'as-is', without any express or implied + * warranty. + */ + +#ifndef _NSIS_EXEHEAD_API_H_ +#define _NSIS_EXEHEAD_API_H_ + +// Starting with NSIS 2.42, you can check the version of the plugin API in exec_flags->plugin_api_version +// The format is 0xXXXXYYYY where X is the major version and Y is the minor version (MAKELONG(y,x)) +// When doing version checks, always remember to use >=, ex: if (pX->exec_flags->plugin_api_version >= NSISPIAPIVER_1_0) {} + +#define NSISPIAPIVER_1_0 0x00010000 +#define NSISPIAPIVER_CURR NSISPIAPIVER_1_0 + +// NSIS Plug-In Callback Messages +enum NSPIM +{ + NSPIM_UNLOAD, // This is the last message a plugin gets, do final cleanup + NSPIM_GUIUNLOAD, // Called after .onGUIEnd +}; + +// Prototype for callbacks registered with extra_parameters->RegisterPluginCallback() +// Return NULL for unknown messages +// Should always be __cdecl for future expansion possibilities +typedef UINT_PTR (*NSISPLUGINCALLBACK)(enum NSPIM); + +// extra_parameters data structures containing other interesting stuff +// but the stack, variables and HWND passed on to plug-ins. +typedef struct +{ + int autoclose; + int all_user_var; + int exec_error; + int abort; + int exec_reboot; // NSIS_SUPPORT_REBOOT + int reboot_called; // NSIS_SUPPORT_REBOOT + int XXX_cur_insttype; // depreacted + int plugin_api_version; // see NSISPIAPIVER_CURR + // used to be XXX_insttype_changed + int silent; // NSIS_CONFIG_SILENT_SUPPORT + int instdir_error; + int rtl; + int errlvl; + int alter_reg_view; + int status_update; +} exec_flags_t; + +#ifndef NSISCALL +# define NSISCALL __stdcall +#endif + +typedef struct { + exec_flags_t *exec_flags; + int (NSISCALL *ExecuteCodeSegment)(int, HWND); + void (NSISCALL *validate_filename)(LPTSTR); + int (NSISCALL *RegisterPluginCallback)(HMODULE, NSISPLUGINCALLBACK); // returns 0 on success, 1 if already registered and < 0 on errors +} extra_parameters; + +// Definitions for page showing plug-ins +// See Ui.c to understand better how they're used + +// sent to the outer window to tell it to go to the next inner window +#define WM_NOTIFY_OUTER_NEXT (WM_USER+0x8) + +// custom pages should send this message to let NSIS know they're ready +#define WM_NOTIFY_CUSTOM_READY (WM_USER+0xd) + +// sent as wParam with WM_NOTIFY_OUTER_NEXT when user cancels - heed its warning +#define NOTIFY_BYE_BYE 'x' + +#endif /* _PLUGIN_H_ */ diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/crt.cpp b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/crt.cpp new file mode 100644 index 0000000000000000000000000000000000000000..440533de202516a2b0220c20e6ef28ea8be2d92c --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/crt.cpp @@ -0,0 +1,105 @@ +#include + +#if defined(_MSC_VER) && _MSC_VER+0 >= 1400 +#if defined(_MSC_FULL_VER) && _MSC_FULL_VER+0 >= 140050727 +#include +#else +EXTERN_C void __stosb(BYTE*,BYTE,size_t); +#endif +#pragma intrinsic(__stosb) +#define CRTINTRINSIC_memset(p,c,s) __stosb((BYTE*)(p),(BYTE)(c),(s)) +#endif + +extern "C" void* __cdecl memset(void *p, int c, size_t z) +{ +#ifdef CRTINTRINSIC_memset + CRTINTRINSIC_memset(p, c, z); +#else + BYTE *pb = reinterpret_cast(p); + for(size_t i=0; istring_size) +* HTTP POST added +* Jun 06, 2005 IDOK on "Enter" key locked +* POST HTTP header added +* Jun 22, 2005 non-interaptable mode /nocancel +* and direct connect /noproxy +* Jun 29, 2005 post.php written and tested +* Jul 05, 2005 60 sec delay on WinInet detach problem +* solved (not fine, but works including +* installer exit and system reboot) +* Jul 08, 2005 'set foreground' finally removed +* Jul 26, 2005 POPUP translate option +* Aug 23, 2005 https service type in InternetConnect +* and "ignore certificate" flags +* Sep 30, 2005 https with bad certificate from old OS; +* Forbidden handling +* Dec 23, 2005 'put' entry point, new names, 12003 +* ftp error handling (on ftp write permission) +* 405 http error (method not allowed) +* Mar 12, 2006 Internal authorization via InternetErrorDlg() +* and Unauthorized (401) handling. +* Jun 10, 2006 Caption text option for Resume download +* MessageBox +* Jun 24, 2006 HEAD method, silent mode clean up +* Sep 05, 2006 Center dialog code from Backland +* Sep 07, 2006 NSISdl crash fix /Backland idea/ +* Sep 08, 2006 POST as dll entry point. +* Sep 21, 2006 parent dlg progr.bar style and font, +* nocancel via ws_sysmenu +* Sep 22, 2006 current lang IDCANCEL text, /canceltext +* and /useragent options +* Sep 24, 2006 .onInit improvements and defaults +* Nov 11, 2006 FTP path creation, root|current dir mgmt +* Jan 01, 2007 Global char[] cleanup, GetLastError() to +* status string on ERR_DIALOG, few MSVCRT replaces +* Jan 13, 2007 /HEADER option added +* Jan 28, 2007 _open -> CreateFile and related +* Feb 18, 2007 Speed calculating improved (pauses), +* /popup text parameter to hide URL +* Jun 07, 2007 Local file truncation added for download +* (CREATE_ALWAYS) +* Jun 11, 2007 FTP download permitted even if server rejects +* SIZE request (ProFTPD). +* Aug 11, 2007 Backland' fix for progress bar redraw/style +* issue in NSISdl display mode. +* Jan 09, 2008 {_trueparuex^}' fix - InternetSetFilePointer() +* returns -1 on error. +* /question option added for cancel question. +* Feb 15, 2008 PUT content-length file size fix +* Feb 17, 2008 char -> TCHAR replace for UNICODE option +* Feb 19, 2008 janekschwarz fix for HTTP PUT with auth +* CreateFile INVALID_HANDLE_VALUE on error fix +* Feb 20, 2008 base64 encoder update for unicode +* Feb 27, 2008 Unicode configurations added to VS 6 dsp +* Mar 20, 2008 HTTP PUT with proxy auth finally fixed +* FTP errors handling improved. +* HEAD bug fixed +* Mar 27, 2008 Details window hide/show in NSISdl mode +* Apr 10, 2008 Auth test method changed to HEAD for +* old proxy's +* Apr 30, 2008 InternetErrorDlg() ERROR_SUCESS on cancel +* click patched +* 3xx errors added to status list. +* May 20, 2008 InternetReadFile on cable disconnect patched +* May 20, 2008 Reply status "0" patch (name resolution?) +* Jul 15, 2008 HTTP 304 parsing. Incorrect size reported fix. +* Aug 21, 2009 Escape sequence convertion removed (caused +* error in signature with %2b requests) +* Marqueue progess bar style for unknown file size. +* Feb 04, 2010 Unicode POST patch - body converted to multibyte +* Jul 11, 2010 /FILE POST option added +* Nov 04, 2010 Disabled cookies and cache for cleanliness +* Feb 14, 2011 Fixed reget bug introduced in previous commit +* Feb 18, 2011 /NOCOOKIES option added +* Mar 02, 2011 User-agent buffer increased. Small memory leak fix +* Mar 23, 2011 Use caption on embedded progressbar - zenpoy +* Apr 05, 2011 reget fix - INTERNET_FLAG_RELOAD for first connect only +* Apr 27, 2011 /receivetimeout option added for big files and antivirus +* Jun 15, 2011 Stack clean up fix on cancel - zenpoy +* Oct 19, 2011 FTP PUT error parsing fix - tperquin +* Aug 19, 2013 Fix focus stealing when in silent - negativesir, JohnTHaller +* Jul 20, 2014 - 1.0.4.4 - Stuart 'Afrow UK' Welch +* /tostack & /tostackconv added +* Version information resource added +* Updated to NSIS 3.0 plugin API +* Upgraded to Visual Studio 2012 +* 64-bit build added +* MSVCRT dependency removed +* Sep 04, 2015 - 1.0.5.0 - anders_k +* HTTPS connections are more secure by default +* Added /weaksecurity switch, reverts to old cert. security checks +* Sep 06, 2015 - 1.0.5.1 - anders_k +* Don't allow infinite FtpCreateDirectory tries +* Use memset intrinsic when possible to avoid VC code generation bug +* Oct 17, 2015 - 1.0.5.2 - anders_k +* Tries to set FTP mode to binary before querying the size. +* Calls FtpGetFileSize if it exists. +* Sep 24, 2018 - 1.0.5.3 - anders_k +* /tostackconv supports UTF-8 and UTF-16LE BOM sniffing and conversion. +*******************************************************/ + + +#define _WIN32_WINNT 0x0500 + +#include +//#include +#include +#include +#include "pluginapi.h" +#include "resource.h" + +#include // strstr etc + +#ifndef PBM_SETMARQUEE +#define PBM_SETMARQUEE (WM_USER + 10) +#define PBS_MARQUEE 0x08 +#endif +#ifndef HTTP_QUERY_PROXY_AUTHORIZATION +#define HTTP_QUERY_PROXY_AUTHORIZATION 61 +#endif +#ifndef SECURITY_FLAG_IGNORE_REVOCATION +#define SECURITY_FLAG_IGNORE_REVOCATION 0x00000080 +#endif +#ifndef SECURITY_FLAG_IGNORE_UNKNOWN_CA +#define SECURITY_FLAG_IGNORE_UNKNOWN_CA 0x00000100 +#endif + +// IE 4 safety and VS 6 compatibility +typedef BOOL (__stdcall *FTP_CMD)(HINTERNET,BOOL,DWORD,LPCTSTR,DWORD,HINTERNET *); +FTP_CMD myFtpCommand; + +#define PLUGIN_NAME TEXT("Inetc plug-in") +#define INETC_USERAGENT TEXT("NSIS_Inetc (Mozilla)") +#define PB_RANGE 400 // progress bar values range +#define PAUSE1_SEC 2 // transfer error indication time, for reget only +#define PAUSE2_SEC 3 // paused state time, increase this if need (60?) +#define PAUSE3_SEC 1 // pause after resume button pressed +#define NOT_AVAILABLE 0xffffffff +#define POST_HEADER TEXT("Content-Type: application/x-www-form-urlencoded") +#define PUT_HEADER TEXT("Content-Type: octet-stream\nContent-Length: %d") +#define INTERNAL_OK 0xFFEE +#define PROGRESS_MS 1000 // screen values update interval +#define DEF_QUESTION TEXT("Are you sure that you want to stop download?") +#define HOST_AUTH_HDR TEXT("Authorization: basic %s") +#define PROXY_AUTH_HDR TEXT("Proxy-authorization: basic %s") + +//#define MY_WEAKSECURITY_CERT_FLAGS SECURITY_FLAG_IGNORE_UNKNOWN_CA | SECURITY_FLAG_IGNORE_REVOCATION | SECURITY_FLAG_IGNORE_CERT_DATE_INVALID | SECURITY_FLAG_IGNORE_CERT_CN_INVALID +#define MY_WEAKSECURITY_CERT_FLAGS SECURITY_FLAG_IGNORE_UNKNOWN_CA | SECURITY_FLAG_IGNORE_REVOCATION +#define MY_REDIR_FLAGS INTERNET_FLAG_IGNORE_REDIRECT_TO_HTTP | INTERNET_FLAG_IGNORE_REDIRECT_TO_HTTPS +#define MY_HTTPS_FLAGS (MY_REDIR_FLAGS | INTERNET_FLAG_SECURE) + +enum STATUS_CODES { + ST_OK = 0, + ST_CONNECTING, + ST_DOWNLOAD, + ST_CANCELLED, + ST_URLOPEN, + // ST_OPENING, + ST_PAUSE, + ERR_TERMINATED, + ERR_DIALOG, + ERR_INETOPEN, + ERR_URLOPEN, + ERR_TRANSFER, + ERR_FILEOPEN, + ERR_FILEWRITE, + ERR_FILEREAD, + ERR_REGET, + ERR_CONNECT, + ERR_OPENREQUEST, + ERR_SENDREQUEST, + ERR_CRACKURL, + ERR_NOTFOUND, + ERR_THREAD, + ERR_PROXY, + ERR_FORBIDDEN, + ERR_NOTALLOWED, + ERR_REQUEST, + ERR_SERVER, + ERR_AUTH, + ERR_CREATEDIR, + ERR_PATH, + ERR_NOTMODIFIED, + ERR_REDIRECTION +}; + + +static TCHAR szStatus[][32] = { + TEXT("OK"),TEXT("Connecting"),TEXT("Downloading"),TEXT("Cancelled"),TEXT("Connecting"), //TEXT("Opening URL")), + TEXT("Reconnect Pause"),TEXT("Terminated"),TEXT("Dialog Error"),TEXT("Open Internet Error"), + TEXT("Open URL Error"),TEXT("Transfer Error"),TEXT("File Open Error"),TEXT("File Write Error"),TEXT("File Read Error"), + TEXT("Reget Error"),TEXT("Connection Error"),TEXT("OpenRequest Error"),TEXT("SendRequest Error"), + TEXT("URL Parts Error"),TEXT("File Not Found (404)"),TEXT("CreateThread Error"),TEXT("Proxy Error (407)"), + TEXT("Access Forbidden (403)"),TEXT("Not Allowed (405)"),TEXT("Request Error"),TEXT("Server Error"), + TEXT("Unauthorized (401)"),TEXT("FtpCreateDir failed (550)"),TEXT("Error FTP path (550)"),TEXT("Not Modified"), + TEXT("Redirection") +}; + +HINSTANCE g_hInstance; +TCHAR fn[MAX_PATH]=TEXT(""), +*url = NULL, +*szAlias = NULL, +*szProxy = NULL, +*szHeader = NULL, +*szBanner = NULL, +*szQuestion = NULL, +szCancel[64]=TEXT(""), +szCaption[128]=TEXT(""), +szUserAgent[256]=TEXT(""), +szResume[256] = TEXT("Your internet connection seems to be not permitted or dropped out!\nPlease reconnect and click Retry to resume installation."); +CHAR *szPost = NULL, +post_fname[MAX_PATH] = ""; +DWORD fSize = 0; +TCHAR *szToStack = NULL; + +int status; +DWORD cnt = 0, +cntToStack = 0, +fs = 0, +timeout = 0, +receivetimeout = 0; +DWORD startTime, transfStart, openType; +bool silent, popup, resume, nocancel, noproxy, nocookies, convToStack, g_ignorecertissues; + +HWND childwnd; +HWND hDlg; +bool fput = false, fhead = false; + + +#define Option_IgnoreCertIssues() ( g_ignorecertissues ) + +static FARPROC GetWininetProcAddress(LPCSTR Name) +{ + return GetProcAddress(LoadLibraryA("WININET"), Name); +} + +/***************************************************** +* FUNCTION NAME: sf(HWND) +* PURPOSE: +* moves HWND to top and activates it +* SPECIAL CONSIDERATIONS: +* commented because annoying +*****************************************************/ +/* +void sf(HWND hw) +{ +DWORD ctid = GetCurrentThreadId(); +DWORD ftid = GetWindowThreadProcessId(GetForegroundWindow(), NULL); +AttachThreadInput(ftid, ctid, TRUE); +SetForegroundWindow(hw); +AttachThreadInput(ftid, ctid, FALSE); +} +*/ + +static TCHAR szUrl[64] = TEXT(""); +static TCHAR szDownloading[64] = TEXT("Downloading %s"); +static TCHAR szConnecting[64] = TEXT("Connecting ..."); +static TCHAR szSecond[64] = TEXT("second"); +static TCHAR szMinute[32] = TEXT("minute"); +static TCHAR szHour[32] = TEXT("hour"); +static TCHAR szPlural[32] = TEXT("s"); +static TCHAR szProgress[128] = TEXT("%dkB (%d%%) of %dkB @ %d.%01dkB/s"); +static TCHAR szRemaining[64] = TEXT(" (%d %s%s remaining)"); +static TCHAR szBasic[128] = TEXT(""); +static TCHAR szAuth[128] = TEXT(""); + +// is it possible to make it working with unicode strings? + +/* Base64 encode one byte */ +static TCHAR encode(unsigned char u) { + + if(u < 26) return TEXT('A')+u; + if(u < 52) return TEXT('a')+(u-26); + if(u < 62) return TEXT('0')+(u-52); + if(u == 62) return TEXT('+'); + return TEXT('/'); +} + +TCHAR *encode_base64(int size, TCHAR *src, TCHAR *dst) { + + int i; + TCHAR *p; + + if(!src) + return NULL; + + if(!size) + size= lstrlen(src); + + p = dst; + + for(i=0; i>2; + b5= ((b1&0x3)<<4)|(b2>>4); + b6= ((b2&0xf)<<2)|(b3>>6); + b7= b3&0x3f; + + *p++= encode(b4); + *p++= encode(b5); + + if(i+1 0) + { + dw = data_buf; + if(!InternetWriteFile(hFile, dw, bytesDone, &rslt) || rslt == 0) + { + status = ERR_TRANSFER; + break; + } + dw += rslt; + cnt += rslt; + bytesDone -= rslt; + } + } + else + { + if(!InternetReadFile(hFile, data_buf, sizeof(data_buf), &rslt)) + { + status = ERR_TRANSFER; + break; + } + if(rslt == 0) // EOF reached or cable disconnect + { +// on cable disconnect returns TRUE and 0 bytes. is cnt == 0 OK (zero file size)? +// cannot check this if reply is chunked (no content-length, http 1.1) + status = (fs != NOT_AVAILABLE && cnt < fs) ? ERR_TRANSFER : ST_OK; + break; + } + if(szToStack) + { + for (DWORD i = 0; cntToStack < g_stringsize && i < rslt; i++, cntToStack++) + if (convToStack) + *((BYTE*)szToStack + cntToStack) = data_buf[i]; // Bytes + else + *(szToStack + cntToStack) = data_buf[i]; // ? to TCHARs + } + else if(!WriteFile(localFile, data_buf, rslt, &bytesDone, NULL) || + rslt != bytesDone) + { + status = ERR_FILEWRITE; + break; + } + cnt += rslt; + } + } +} + +/***************************************************** +* FUNCTION NAME: mySendRequest() +* PURPOSE: +* HttpSendRequestEx() sends headers only - for PUT +* We also can use InetWriteFile for POST body I guess +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +int mySendRequest(HINTERNET hFile) +{ + INTERNET_BUFFERS BufferIn = {0}; + if(fput) + { + BufferIn.dwStructSize = sizeof( INTERNET_BUFFERS ); + BufferIn.dwBufferTotal = fs; + return HttpSendRequestEx( hFile, &BufferIn, NULL, HSR_INITIATE, 0); + } + return HttpSendRequest(hFile, NULL, 0, szPost, fSize); +} + +/***************************************************** +* FUNCTION NAME: queryStatus() +* PURPOSE: +* http status code comes before download (get) and +* after upload (put), so this is called from 2 places +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +bool queryStatus(HINTERNET hFile) +{ + TCHAR buf[256] = TEXT(""); + DWORD rslt; + if(HttpQueryInfo(hFile, HTTP_QUERY_STATUS_CODE, + buf, &(rslt = sizeof(buf)), NULL)) + { + buf[3] = 0; + if(lstrcmp(buf, TEXT("0")) == 0 || *buf == 0) + status = ERR_SENDREQUEST; + else if(lstrcmp(buf, TEXT("401")) == 0) + status = ERR_AUTH; + else if(lstrcmp(buf, TEXT("403")) == 0) + status = ERR_FORBIDDEN; + else if(lstrcmp(buf, TEXT("404")) == 0) + status = ERR_NOTFOUND; + else if(lstrcmp(buf, TEXT("407")) == 0) + status = ERR_PROXY; + else if(lstrcmp(buf, TEXT("405")) == 0) + status = ERR_NOTALLOWED; + else if(lstrcmp(buf, TEXT("304")) == 0) + status = ERR_NOTMODIFIED; + else if(*buf == TEXT('3')) + { + status = ERR_REDIRECTION; + wsprintf(szStatus[status] + lstrlen(szStatus[status]), TEXT(" (%s)"), buf); + } + else if(*buf == TEXT('4')) + { + status = ERR_REQUEST; + wsprintf(szStatus[status] + lstrlen(szStatus[status]), TEXT(" (%s)"), buf); + } + else if(*buf == TEXT('5')) + { + status = ERR_SERVER; + wsprintf(szStatus[status] + lstrlen(szStatus[status]), TEXT(" (%s)"), buf); + } + return true; + } + return false; +} + +/***************************************************** +* FUNCTION NAME: openFtpFile() +* PURPOSE: +* control connection, size request, re-get lseek +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +HINTERNET openFtpFile(HINTERNET hConn, + TCHAR *path) +{ + TCHAR buf[256] = TEXT(""), *movp; + HINTERNET hFile; + DWORD rslt, err, gle; + bool https_req_ok = false; + + /* reads connection / auth responce info and cleares 'control' buffer this way */ + InternetGetLastResponseInfo(&err, buf, &(rslt = sizeof(buf))); + if(cnt == 0) + { + if(!fput) // we know local file size already + { + if (myFtpCommand) + { + /* Try to set the REPRESENTATION TYPE to I[mage] (Binary) because some servers + don't accept the SIZE command in ASCII mode */ + myFtpCommand(hConn, false, FTP_TRANSFER_TYPE_ASCII, TEXT("TYPE I"), 0, &hFile); + } + /* too clever myFtpCommand returnes false on the valid TEXT("550 Not found/Not permitted" server answer, + to read answer I had to ignory returned false (!= 999999) :-( + GetLastError also possible, but MSDN description of codes is very limited */ + wsprintf(buf, TEXT("SIZE %s"), path + 1); + if(myFtpCommand != NULL && + myFtpCommand(hConn, false, FTP_TRANSFER_TYPE_ASCII, buf, 0, &hFile) != 9999 && + memset(buf, 0, sizeof(buf)) != NULL && + InternetGetLastResponseInfo(&err, buf, &(rslt = sizeof(buf)))) + { + if(_tcsstr(buf, TEXT("213 "))) + { + fs = myatou(_tcschr(buf, TEXT(' ')) + 1); + } + /* stupid ProFTPD returns error on SIZE request. let's continue without size. + But IE knows some trick to get size from ProFTPD...... + else if(mystrstr(buf, TEXT("550 TEXT(")) + { + status = ERR_SIZE_NOT_PERMITTED; + return NULL; + } + */ + } + if(fs == 0) + { + fs = NOT_AVAILABLE; + } + } + } + else + { + wsprintf(buf, TEXT("REST %d"), cnt); + if(myFtpCommand == NULL || + !myFtpCommand(hConn, false, FTP_TRANSFER_TYPE_BINARY, buf, 0, &hFile) || + memset(buf, 0, sizeof(buf)) == NULL || + !InternetGetLastResponseInfo(&err, buf, &(rslt = sizeof(buf))) || + (_tcsstr(buf, TEXT("350")) == NULL && _tcsstr(buf, TEXT("110")) == NULL)) + { + status = ERR_REGET; + return NULL; + } + } + if((hFile = FtpOpenFile(hConn, path + 1, fput ? GENERIC_WRITE : GENERIC_READ, + FTP_TRANSFER_TYPE_BINARY|INTERNET_FLAG_RELOAD,0)) == NULL) + { + gle = GetLastError(); + *buf = 0; + InternetGetLastResponseInfo(&err, buf, &(rslt = sizeof(buf))); + // wrong path - dir may not exist or upload may be not allowed + // we use ftp://host//path (double /) to define path from FS root + if(fput && (_tcsstr(buf, TEXT("550")) != NULL || _tcsstr(buf, TEXT("553")) != NULL)) + { + movp = path + 1; + if(*movp == TEXT('/')) movp++; // don't need to create root + for (UINT8 escapehatch = 0; ++escapehatch;) // Weak workaround for http://forums.winamp.com/showpost.php?p=3031692&postcount=513 bug + { + TCHAR *pbs = _tcschr(movp, TEXT('/')); + if (!pbs) break; + *pbs = TEXT('\0'); + FtpCreateDirectory(hConn, path + 1); + InternetGetLastResponseInfo(&err, buf, &(rslt = sizeof(buf))); + *(movp + lstrlen(movp)) = TEXT('/'); + movp = _tcschr(movp, TEXT('/')) + 1; + } + if(status != ERR_CREATEDIR && + (hFile = FtpOpenFile(hConn, path + 1, GENERIC_WRITE, + FTP_TRANSFER_TYPE_BINARY|INTERNET_FLAG_RELOAD,0)) == NULL) + { + status = ERR_PATH; + if(InternetGetLastResponseInfo(&err, buf, &(rslt = sizeof(buf)))) + lstrcpyn(szStatus[status], _tcsstr(buf, TEXT("550")), sizeof(szStatus[0]) / sizeof(TCHAR)); + } + } + // may be firewall related error, let's give user time to disable it + else if(gle == 12003) // ERROR_INTERNET_EXTENDED_ERROR + { + if(_tcsstr(buf, TEXT("550"))) + { + status = ERR_NOTFOUND; + lstrcpyn(szStatus[status], _tcsstr(buf, TEXT("550")), sizeof(szStatus[0]) / sizeof(TCHAR)); + } + else + { + lstrcpyn(szStatus[status], buf, sizeof(szStatus[0]) / sizeof(TCHAR)); + } + } + // timeout (firewall or dropped connection problem) + else if(gle == 12002) // ERROR_INTERNET_TIMEOUT + { + if(!silent) + resume = true; + status = ERR_URLOPEN; + } + } + else + InternetGetLastResponseInfo(&err, buf, &(rslt = sizeof(buf))); + if (hFile && NOT_AVAILABLE == fs) + { + FARPROC ftpgfs = GetWininetProcAddress("FtpGetFileSize"); // IE5+ + if (ftpgfs) + { + DWORD shi, slo = ((DWORD(WINAPI*)(HINTERNET,DWORD*))ftpgfs)(hFile, &shi); + if (slo != -1 && !shi) fs = slo; + } + } + return hFile; +} + + +/***************************************************** +* FUNCTION NAME: openHttpFile() +* PURPOSE: +* file open, size request, re-get lseek +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +HINTERNET openHttpFile(HINTERNET hConn, INTERNET_SCHEME nScheme, TCHAR *path) +{ + TCHAR buf[256] = TEXT(""); + HINTERNET hFile; + DWORD rslt, err; + bool first_attempt = true;; + +// test connection for PUT, the only way to do this before sending data +// OPTIONS fails on HttpOpenRequest step for HTTPS +// but works for HEAD I guess + if(fput)// && nScheme != INTERNET_SCHEME_HTTPS) + { +// old proxy's may not support OPTIONS request, so changed to HEAD.... + if((hFile = HttpOpenRequest(hConn, TEXT("HEAD"), path, NULL, NULL, NULL, +// if((hFile = HttpOpenRequest(hConn, TEXT("OPTIONS"), path, NULL, NULL, NULL, + INTERNET_FLAG_RELOAD | INTERNET_FLAG_KEEP_CONNECTION | + (nocookies ? (INTERNET_FLAG_NO_CACHE_WRITE | INTERNET_FLAG_NO_COOKIES) : 0), 0)) != NULL) + { + if(*szAuth) + { + wsprintf(buf, PROXY_AUTH_HDR, szAuth); + HttpAddRequestHeaders(hFile, buf, -1, + HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE); + } +resend_proxy1: + if(*szBasic) + { + wsprintf(buf, HOST_AUTH_HDR, szBasic); + HttpAddRequestHeaders(hFile, buf, -1, + HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE); + } +resend_auth1: + if(HttpSendRequest(hFile, NULL, 0, NULL, 0)) + { + queryStatus(hFile); +// may be don't need to read all from socket, but this looks safer + while(InternetReadFile(hFile, buf, sizeof(buf), &rslt) && rslt > 0) {} + if(!silent && (status == ERR_PROXY || status == ERR_AUTH))// || status == ERR_FORBIDDEN)) + { + rslt = InternetErrorDlg(hDlg, hFile, + ERROR_INTERNET_INCORRECT_PASSWORD, + FLAGS_ERROR_UI_FILTER_FOR_ERRORS | + FLAGS_ERROR_UI_FLAGS_GENERATE_DATA | + FLAGS_ERROR_UI_FLAGS_CHANGE_OPTIONS, + NULL); + if (rslt == ERROR_INTERNET_FORCE_RETRY) + { + status = ST_URLOPEN; + if(status == ERR_PROXY) goto resend_proxy1; + else goto resend_auth1; + } + else + { + status = ST_CANCELLED; + } + + } + // no such file is OK for PUT. server first checks authentication + if(status == ERR_NOTFOUND || status == ERR_FORBIDDEN || status == ERR_NOTALLOWED) + { +// MessageBox(childwnd, TEXT("NOT_FOUND"), "", 0); + status = ST_URLOPEN; + } + // parameters might be updated during dialog popup + if(status == ST_URLOPEN) + { + *buf = 0; + if(HttpQueryInfo(hFile, HTTP_QUERY_AUTHORIZATION, buf, &(rslt = sizeof(buf)), NULL) && *buf) + lstrcpyn(szBasic, buf, rslt); + *buf = 0; + if(HttpQueryInfo(hFile, HTTP_QUERY_PROXY_AUTHORIZATION, buf, &(rslt = sizeof(buf)), NULL) && *buf) + lstrcpyn(szAuth, buf, rslt); + } + } + else status = ERR_SENDREQUEST; + InternetCloseHandle(hFile); + } + else status = ERR_OPENREQUEST; + } +// request itself + if(status == ST_URLOPEN) + { + DWORD secflags = nScheme == INTERNET_SCHEME_HTTPS ? MY_HTTPS_FLAGS : 0; + if (Option_IgnoreCertIssues()) secflags |= MY_WEAKSECURITY_CERT_FLAGS; + DWORD cokflags = nocookies ? (INTERNET_FLAG_NO_CACHE_WRITE | INTERNET_FLAG_NO_COOKIES) : 0; + if((hFile = HttpOpenRequest(hConn, fput ? TEXT("PUT") : (fhead ? TEXT("HEAD") : (szPost ? TEXT("POST") : NULL)), + path, NULL, NULL, NULL, + // INTERNET_FLAG_RELOAD conflicts with reget - hidden re-read from beginning has place + // INTERNET_FLAG_RESYNCHRONIZE // note - sync may not work with some http servers + // reload on first connect (and any req. except GET), just continue on resume. + // HTTP Proxy still is a problem for reget + (cnt ? 0 : INTERNET_FLAG_RELOAD) | INTERNET_FLAG_KEEP_CONNECTION | cokflags | secflags, 0)) != NULL) + { + if(*szAuth) + { + wsprintf(buf, PROXY_AUTH_HDR, szAuth); + HttpAddRequestHeaders(hFile, buf, -1, + HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE); + } +resend_proxy2: + if(szPost != NULL) + HttpAddRequestHeaders(hFile, POST_HEADER, + -1, HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE); + if(*post_fname) + HttpAddRequestHeadersA(hFile, post_fname, + -1, HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE); + if(szHeader != NULL) + HttpAddRequestHeaders(hFile, szHeader, -1, + HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE); + if(*szBasic) + { + wsprintf(buf, HOST_AUTH_HDR, szBasic); + HttpAddRequestHeaders(hFile, buf, -1, + HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE); + } + if(fput) + { + wsprintf(buf, PUT_HEADER, fs); + HttpAddRequestHeaders(hFile, buf, -1, + HTTP_ADDREQ_FLAG_ADD | HTTP_ADDREQ_FLAG_REPLACE); + } +resend_auth2: + first_attempt = true; + if(nScheme == INTERNET_SCHEME_HTTPS) + { + if(!mySendRequest(hFile)) + { + InternetQueryOption (hFile, INTERNET_OPTION_SECURITY_FLAGS, + (LPVOID)&rslt, &(err = sizeof(rslt))); + rslt |= Option_IgnoreCertIssues() ? MY_WEAKSECURITY_CERT_FLAGS : 0; + InternetSetOption (hFile, INTERNET_OPTION_SECURITY_FLAGS, + &rslt, sizeof(rslt) ); + } + else first_attempt = false; + } +// https Request answer may be after optional second Send only on Win98 + if(!first_attempt || mySendRequest(hFile)) + { +// no status for PUT - headers were sent only. And not need to get size / set position + if(!fput) + { + queryStatus(hFile); + if(!silent && (status == ERR_PROXY || status == ERR_AUTH)) + { + rslt = InternetErrorDlg(hDlg, hFile, + ERROR_INTERNET_INCORRECT_PASSWORD, + FLAGS_ERROR_UI_FILTER_FOR_ERRORS | + FLAGS_ERROR_UI_FLAGS_GENERATE_DATA | + FLAGS_ERROR_UI_FLAGS_CHANGE_OPTIONS, + NULL); + if (rslt == ERROR_INTERNET_FORCE_RETRY) + { + status = ST_URLOPEN; + if(status == ERR_PROXY) goto resend_proxy2; + else goto resend_auth2; + } + else + status = ST_CANCELLED; + + } +// get size / set position + if(status == ST_URLOPEN) + { + if(cnt == 0) + { + if(HttpQueryInfo(hFile, HTTP_QUERY_CONTENT_LENGTH, buf, + &(rslt = sizeof(buf)), NULL)) + fs = myatou(buf); + else + fs = NOT_AVAILABLE; + } + else + { + if((int)InternetSetFilePointer(hFile, cnt, NULL, FILE_BEGIN, 0) == -1) + status = ERR_REGET; + } + } + } + } + else + { + if(!queryStatus(hFile)) + status = ERR_SENDREQUEST; + } + } + else status = ERR_OPENREQUEST; + } + return hFile; +} + +/***************************************************** +* FUNCTION NAME: inetTransfer() +* PURPOSE: +* http/ftp file transfer +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +DWORD __stdcall inetTransfer(void *hw) +{ + HINTERNET hSes, hConn, hFile; + HANDLE localFile = NULL; + HWND hDlg = (HWND)hw; + DWORD lastCnt, rslt, err; + static TCHAR hdr[2048]; + TCHAR *host = (TCHAR*)LocalAlloc(LPTR, g_stringsize * sizeof(TCHAR)), + *path = (TCHAR*)LocalAlloc(LPTR, g_stringsize * sizeof(TCHAR)), + *params = (TCHAR*)LocalAlloc(LPTR, g_stringsize * sizeof(TCHAR)), + *user = (TCHAR*)LocalAlloc(LPTR, g_stringsize * sizeof(TCHAR)), + *passwd = (TCHAR*)LocalAlloc(LPTR, g_stringsize * sizeof(TCHAR)); + + URL_COMPONENTS uc = {sizeof(URL_COMPONENTS), NULL, 0, + (INTERNET_SCHEME)0, host, g_stringsize, 0 , user, g_stringsize, + passwd, g_stringsize, path, g_stringsize, params, g_stringsize}; + + if((hSes = InternetOpen(szUserAgent, openType, szProxy, NULL, 0)) != NULL) + { + if(InternetQueryOption(hSes, INTERNET_OPTION_CONNECTED_STATE, &(rslt=0), + &(lastCnt=sizeof(DWORD))) && + (rslt & INTERNET_STATE_DISCONNECTED_BY_USER)) + { + INTERNET_CONNECTED_INFO ci = {INTERNET_STATE_CONNECTED, 0}; + InternetSetOption(hSes, + INTERNET_OPTION_CONNECTED_STATE, &ci, sizeof(ci)); + } + if(timeout > 0) + lastCnt = InternetSetOption(hSes, INTERNET_OPTION_CONNECT_TIMEOUT, &timeout, sizeof(timeout)); + if(receivetimeout > 0) + InternetSetOption(hSes, INTERNET_OPTION_RECEIVE_TIMEOUT, &receivetimeout, sizeof(receivetimeout)); + // 60 sec WinInet.dll detach delay on socket time_wait fix + myFtpCommand = (FTP_CMD) GetWininetProcAddress( +#ifdef UNICODE + "FtpCommandW" +#else + "FtpCommandA" +#endif + ); + while(!popstring(url) && lstrcmpi(url, TEXT("/end")) != 0) + { + // too many customers requested not to do this + // sf(hDlg); + if(popstring(fn) != 0 || lstrcmpi(url, TEXT("/end")) == 0) break; + status = ST_CONNECTING; + cnt = fs = *host = *user = *passwd = *path = *params = 0; + PostMessage(hDlg, WM_TIMER, 1, 0); // show url & fn, do it sync + if(szToStack || (localFile = CreateFile(fn, fput ? GENERIC_READ : GENERIC_WRITE, FILE_SHARE_READ, + NULL, fput ? OPEN_EXISTING : CREATE_ALWAYS, 0, NULL)) != INVALID_HANDLE_VALUE) + { + uc.dwHostNameLength = uc.dwUserNameLength = uc.dwPasswordLength = + uc.dwUrlPathLength = uc.dwExtraInfoLength = g_stringsize; + if(fput) + { + fs = GetFileSize(localFile, NULL); + } + if(InternetCrackUrl(url, 0, 0/*ICU_ESCAPE*/ , &uc)) + { + // auth headers for HTTP PUT seems to be lost, preparing encoded login:password + if(*user && *passwd) + { + wsprintf(hdr, TEXT("%s:%s"), user, passwd); + // does unicode version of encoding works correct? + // are user and passwd ascii only? + encode_base64(lstrlen(hdr), hdr, szBasic); + *hdr = 0; + } + lstrcat(path, params); // BUGBUG: Could overflow path? + transfStart = GetTickCount(); + do + { + // re-PUT to already deleted tmp file on http server is not possible. + // the same with POST - must re-send data to server. for 'resume' loop + if((fput && uc.nScheme != INTERNET_SCHEME_FTP) || szPost) + { + cnt = 0; + SetFilePointer(localFile, 0, NULL, FILE_BEGIN); + } + status = ST_CONNECTING; + lastCnt = cnt; + if((hConn = InternetConnect(hSes, host, uc.nPort, + lstrlen(user) > 0 ? user : NULL, + lstrlen(passwd) > 0 ? passwd : NULL, + uc.nScheme == INTERNET_SCHEME_FTP ? INTERNET_SERVICE_FTP : INTERNET_SERVICE_HTTP, + uc.nScheme == INTERNET_SCHEME_FTP ? INTERNET_FLAG_PASSIVE : 0, 0)) != NULL) + { + status = ST_URLOPEN; + hFile = uc.nScheme == INTERNET_SCHEME_FTP ? + openFtpFile(hConn, path) : openHttpFile(hConn, uc.nScheme, path); + if(status != ST_URLOPEN && hFile != NULL) + { + InternetCloseHandle(hFile); + hFile = NULL; + } + if(hFile != NULL) + { + if(fhead) + {// repeating calls clear headers.. + if(HttpQueryInfo(hFile, HTTP_QUERY_RAW_HEADERS_CRLF, hdr, &(rslt=2048), NULL)) + { + if(szToStack) + { + for (DWORD i = 0; cntToStack < g_stringsize && i < rslt; i++, cntToStack++) + *(szToStack + cntToStack) = hdr[i]; // ASCII to TCHAR + } + else + { + WriteFile(localFile, hdr, rslt, &lastCnt, NULL); + } + } + status = ST_OK; + } + else + { + HWND hBar = GetDlgItem(hDlg, IDC_PROGRESS1); + SendDlgItemMessage(hDlg, IDC_PROGRESS1, PBM_SETPOS, 0, 0); + SetWindowText(GetDlgItem(hDlg, IDC_STATIC5), fs == NOT_AVAILABLE ? TEXT("Not Available") : TEXT("")); + SetWindowText(GetDlgItem(hDlg, IDC_STATIC4), fs == NOT_AVAILABLE ? TEXT("Unknown") : TEXT("")); + SetWindowLong(hBar, GWL_STYLE, fs == NOT_AVAILABLE ? + (GetWindowLong(hBar, GWL_STYLE) | PBS_MARQUEE) : (GetWindowLong(hBar, GWL_STYLE) & ~PBS_MARQUEE)); + SendDlgItemMessage(hDlg, IDC_PROGRESS1, PBM_SETMARQUEE, (WPARAM)(fs == NOT_AVAILABLE ? 1 : 0), (LPARAM)50 ); + fileTransfer(localFile, hFile); + if(fput && uc.nScheme != INTERNET_SCHEME_FTP) + { + rslt = HttpEndRequest(hFile, NULL, 0, 0); + queryStatus(hFile); + } + } + InternetCloseHandle(hFile); + } + InternetCloseHandle(hConn); + } + else + { + status = ERR_CONNECT; + if(uc.nScheme == INTERNET_SCHEME_FTP && + InternetGetLastResponseInfo(&err, hdr, &(rslt = sizeof(hdr))) && + _tcsstr(hdr, TEXT("530"))) + { + lstrcpyn(szStatus[status], _tcsstr(hdr, TEXT("530")), sizeof(szStatus[0]) / sizeof(TCHAR)); + } + else + { + rslt = GetLastError(); + if((rslt == 12003 || rslt == 12002) && !silent) + resume = true; + } + } + } while(((!fput || uc.nScheme == INTERNET_SCHEME_FTP) && + cnt > lastCnt && + status == ERR_TRANSFER && + SleepEx(PAUSE1_SEC * 1000, false) == 0 && + (status = ST_PAUSE) != ST_OK && + SleepEx(PAUSE2_SEC * 1000, false) == 0) + || (resume && + status != ST_OK && + status != ST_CANCELLED && + status != ERR_NOTFOUND && + ShowWindow(hDlg, SW_HIDE) != -1 && + MessageBox(GetParent(hDlg), szResume, *szCaption ? szCaption : PLUGIN_NAME, MB_RETRYCANCEL|MB_ICONWARNING) == IDRETRY && + (status = ST_PAUSE) != ST_OK && + ShowWindow(hDlg, silent ? SW_HIDE : SW_SHOW) == false && + SleepEx(PAUSE3_SEC * 1000, false) == 0)); + } + else status = ERR_CRACKURL; + CloseHandle(localFile); + if(!fput && status != ST_OK && !szToStack) + { + rslt = DeleteFile(fn); + break; + } + } + else status = ERR_FILEOPEN; + } + InternetCloseHandle(hSes); + if (lstrcmpi(url, TEXT("/end"))==0) + pushstring(url); + } + else status = ERR_INETOPEN; + LocalFree(host); + LocalFree(path); + LocalFree(user); + LocalFree(passwd); + LocalFree(params); + if(IsWindow(hDlg)) + PostMessage(hDlg, WM_COMMAND, MAKELONG(IDOK, INTERNAL_OK), 0); + return status; +} + +/***************************************************** +* FUNCTION NAME: fsFormat() +* PURPOSE: +* formats DWORD (max 4 GB) file size for dialog, big MB +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +void fsFormat(DWORD bfs, TCHAR *b) +{ + if(bfs == NOT_AVAILABLE) + lstrcpy(b, TEXT("???")); + else if(bfs == 0) + lstrcpy(b, TEXT("0")); + else if(bfs < 10 * 1024) + wsprintf(b, TEXT("%u bytes"), bfs); + else if(bfs < 10 * 1024 * 1024) + wsprintf(b, TEXT("%u kB"), bfs / 1024); + else wsprintf(b, TEXT("%u MB"), (bfs / 1024 / 1024)); +} + + +/***************************************************** +* FUNCTION NAME: progress_callback +* PURPOSE: +* old-style progress bar text updates +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ + +void progress_callback(void) +{ + static TCHAR buf[1024] = TEXT(""), b[1024] = TEXT(""); + int time_sofar = max(1, (GetTickCount() - transfStart) / 1000); + int bps = cnt / time_sofar; + int remain = (cnt > 0 && fs != NOT_AVAILABLE) ? (MulDiv(time_sofar, fs, cnt) - time_sofar) : 0; + TCHAR *rtext=szSecond; + if(remain < 0) remain = 0; + if (remain >= 60) + { + remain/=60; + rtext=szMinute; + if (remain >= 60) + { + remain/=60; + rtext=szHour; + } + } + wsprintf(buf, + szProgress, + cnt/1024, + fs > 0 && fs != NOT_AVAILABLE ? MulDiv(100, cnt, fs) : 0, + fs != NOT_AVAILABLE ? fs/1024 : 0, + bps/1024,((bps*10)/1024)%10 + ); + if (remain) wsprintf(buf + lstrlen(buf), + szRemaining, + remain, + rtext, + remain==1?TEXT(""):szPlural + ); + SetDlgItemText(hDlg, IDC_STATIC1, (cnt == 0 || status == ST_CONNECTING) ? szConnecting : buf); + if(fs > 0 && fs != NOT_AVAILABLE) + SendMessage(GetDlgItem(hDlg, IDC_PROGRESS1), PBM_SETPOS, MulDiv(cnt, PB_RANGE, fs), 0); + if (*szCaption == 0) + wsprintf(buf, szDownloading, _tcsrchr(fn, TEXT('\\')) ? _tcsrchr(fn, TEXT('\\')) + 1 : fn); + else + wsprintf(buf, TEXT("%s"),szCaption); + HWND hwndS = GetDlgItem(childwnd, 1006); + if(!silent && hwndS != NULL && IsWindow(hwndS)) + { + GetWindowText(hwndS, b, sizeof(b)); + if(lstrcmp(b, buf) != 0) + SetWindowText(hwndS, buf); + } +} + +/***************************************************** +* FUNCTION NAME: onTimer() +* PURPOSE: +* updates text fields every second +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +void onTimer(HWND hDlg) +{ + TCHAR b[128]; + DWORD ct = (GetTickCount() - transfStart) / 1000, + tt = (GetTickCount() - startTime) / 1000; + // dialog window caption + wsprintf(b, TEXT("%s - %s"), *szCaption ? szCaption : PLUGIN_NAME, szStatus[status]); + if(fs > 0 && fs != NOT_AVAILABLE && status == ST_DOWNLOAD) + { + wsprintf(b + lstrlen(b), TEXT(" %d%%"), MulDiv(100, cnt, fs)); + } + if(szBanner == NULL) SetWindowText(hDlg, b); + // current file and url + SetDlgItemText(hDlg, IDC_STATIC1, (szAlias && *szAlias) ? szAlias : url); + SetDlgItemText(hDlg, IDC_STATIC2, /*_tcsrchr(fn, '\\') ? _tcsrchr(fn, '\\') + 1 : */fn); + // bytes done and rate + if(cnt > 0) + { + fsFormat(cnt, b); + if(ct > 1 && status == ST_DOWNLOAD) + { + lstrcat(b, TEXT(" ( ")); + fsFormat(cnt / ct, b + lstrlen(b)); + lstrcat(b, TEXT("/sec )")); + } + } + else *b = 0; + SetDlgItemText(hDlg, IDC_STATIC3, b); + // total download time + wsprintf(b, TEXT("%d:%02d:%02d"), tt / 3600, (tt / 60) % 60, tt % 60); + SetDlgItemText(hDlg, IDC_STATIC6, b); + // file size, time remaining, progress bar + if(fs > 0 && fs != NOT_AVAILABLE) + { + fsFormat(fs, b); + SetDlgItemText(hDlg, IDC_STATIC5, b); + SendDlgItemMessage(hDlg, IDC_PROGRESS1, PBM_SETPOS, MulDiv(cnt, PB_RANGE, fs), 0); + if(cnt > 5000) + { + ct = MulDiv(fs - cnt, ct, cnt); + wsprintf(b, TEXT("%d:%02d:%02d"), ct / 3600, (ct / 60) % 60, ct % 60); + } + else *b = 0; + SetWindowText(GetDlgItem(hDlg, IDC_STATIC4), b); + } +} + +/***************************************************** +* FUNCTION NAME: centerDlg() +* PURPOSE: +* centers dlg on NSIS parent +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +void centerDlg(HWND hDlg) +{ + HWND hwndParent = GetParent(hDlg); + RECT nsisRect, dlgRect, waRect; + int dlgX, dlgY, dlgWidth, dlgHeight; + + if(hwndParent == NULL || silent) + return; + if(popup) + GetWindowRect(hwndParent, &nsisRect); + else GetClientRect(hwndParent, &nsisRect); + GetWindowRect(hDlg, &dlgRect); + + dlgWidth = dlgRect.right - dlgRect.left; + dlgHeight = dlgRect.bottom - dlgRect.top; + dlgX = (nsisRect.left + nsisRect.right - dlgWidth) / 2; + dlgY = (nsisRect.top + nsisRect.bottom - dlgHeight) / 2; + + if(popup) + { + SystemParametersInfo(SPI_GETWORKAREA, 0, &waRect, 0); + if(dlgX > waRect.right - dlgWidth) + dlgX = waRect.right - dlgWidth; + if(dlgX < waRect.left) dlgX = waRect.left; + if(dlgY > waRect.bottom - dlgHeight) + dlgY = waRect.bottom - dlgHeight; + if(dlgY < waRect.top) dlgY = waRect.top; + } + else dlgY += 20; + + SetWindowPos(hDlg, HWND_TOP, dlgX, dlgY, 0, 0, SWP_NOSIZE); +} + +/***************************************************** +* FUNCTION NAME: onInitDlg() +* PURPOSE: +* dlg init +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +void onInitDlg(HWND hDlg) +{ + HFONT hFont; + HWND hPrbNew; + HWND hPrbOld; + HWND hCan = GetDlgItem(hDlg, IDCANCEL); + + if(childwnd) + { + hPrbNew = GetDlgItem(hDlg, IDC_PROGRESS1); + hPrbOld = GetDlgItem(childwnd, 0x3ec); + + // Backland' fix for progress bar redraw/style issue. + // Original bar may be hidden because of interfernce with other plug-ins. + LONG prbStyle = WS_VISIBLE | WS_CHILD | WS_CLIPSIBLINGS | WS_CLIPCHILDREN; + if(hPrbOld != NULL) + { + prbStyle |= GetWindowLong(hPrbOld, GWL_STYLE); + } + SetWindowLong(hPrbNew, GWL_STYLE, prbStyle); + + if(!popup) + { + if((hFont = (HFONT)SendMessage(childwnd, WM_GETFONT, 0, 0)) != NULL) + { + SendDlgItemMessage(hDlg, IDC_STATIC1, WM_SETFONT, (WPARAM)hFont, 0); + SendDlgItemMessage(hDlg, IDCANCEL, WM_SETFONT, (WPARAM)hFont, 0); + } + if(*szCancel == 0) + GetWindowText(GetDlgItem(GetParent(childwnd), IDCANCEL), szCancel, sizeof(szCancel)); + SetWindowText(hCan, szCancel); + SetWindowPos(hPrbNew, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE); + } + } + + if(nocancel) + { + if(hCan != NULL) + ShowWindow(hCan, SW_HIDE); + if(popup) + SetWindowLong(hDlg, GWL_STYLE, GetWindowLong(hDlg, GWL_STYLE) ^ WS_SYSMENU); + } + SendDlgItemMessage(hDlg, IDC_PROGRESS1, PBM_SETRANGE, + 0, MAKELPARAM(0, PB_RANGE)); + if(szBanner != NULL) + { + SendDlgItemMessage(hDlg, IDC_STATIC13, STM_SETICON, + (WPARAM)LoadIcon(GetModuleHandle(NULL), MAKEINTRESOURCE(103)), 0); + SetDlgItemText(hDlg, IDC_STATIC12, szBanner); + SetWindowText(hDlg, *szCaption ? szCaption : PLUGIN_NAME); + } + SetTimer(hDlg, 1, 1000, NULL); + if(*szUrl != 0) + { + SetDlgItemText(hDlg, IDC_STATIC20, szUrl); + SetDlgItemText(hDlg, IDC_STATIC21, szDownloading); + SetDlgItemText(hDlg, IDC_STATIC22, szConnecting); + SetDlgItemText(hDlg, IDC_STATIC23, szProgress); + SetDlgItemText(hDlg, IDC_STATIC24, szSecond); + SetDlgItemText(hDlg, IDC_STATIC25, szRemaining); + } +} + +/***************************************************** +* FUNCTION NAME: dlgProc() +* PURPOSE: +* dlg message handling procedure +* SPECIAL CONSIDERATIONS: +* todo: better dialog design +*****************************************************/ +INT_PTR CALLBACK dlgProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) +{ + switch(message) + { + case WM_INITDIALOG: + onInitDlg(hDlg); + centerDlg(hDlg); + return false; + case WM_PAINT: + // child dialog redraw problem. return false is important + { + HWND hS1 = GetDlgItem(hDlg, IDC_STATIC1), hC = GetDlgItem(hDlg, IDCANCEL), hP1 = GetDlgItem(hDlg, IDC_PROGRESS1); + RedrawWindow(hS1, NULL, NULL, RDW_INVALIDATE); + RedrawWindow(hC, NULL, NULL, RDW_INVALIDATE); + RedrawWindow(hP1, NULL, NULL, RDW_INVALIDATE); + UpdateWindow(hS1); + UpdateWindow(hC); + UpdateWindow(hP1); + } + return false; + case WM_TIMER: + if(!silent && IsWindow(hDlg)) + { + // long connection period and paused state updates + if(status != ST_DOWNLOAD && GetTickCount() - transfStart > PROGRESS_MS) + transfStart += PROGRESS_MS; + if(popup) onTimer(hDlg); else progress_callback(); + RedrawWindow(GetDlgItem(hDlg, IDC_STATIC1), NULL, NULL, RDW_INVALIDATE); + RedrawWindow(GetDlgItem(hDlg, IDCANCEL), NULL, NULL, RDW_INVALIDATE); + RedrawWindow(GetDlgItem(hDlg, IDC_PROGRESS1), NULL, NULL, RDW_INVALIDATE); + } + break; + case WM_COMMAND: + switch(LOWORD(wParam)) + { + case IDCANCEL: + if(nocancel) break; + if(szQuestion && + MessageBox(hDlg, szQuestion, *szCaption ? szCaption : PLUGIN_NAME, MB_ICONWARNING|MB_YESNO) == IDNO) + break; + status = ST_CANCELLED; + // FallThrough + case IDOK: + if(status != ST_CANCELLED && HIWORD(wParam) != INTERNAL_OK) break; + // otherwise in the silent mode next banner windows may go to background + // if(silent) sf(hDlg); + KillTimer(hDlg, 1); + DestroyWindow(hDlg); + break; + } + return false; + default: + return false; + } + return true; +} + +/***************************************************** +* FUNCTION NAME: get() +* PURPOSE: +* http/https/ftp file download entry point +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +extern "C" +void __declspec(dllexport) __cdecl get(HWND hwndParent, + int string_size, + TCHAR *variables, + stack_t **stacktop, + extra_parameters *extra + ) +{ + HANDLE hThread; + DWORD dwThreadId; + MSG msg; + TCHAR szUsername[64]=TEXT(""), // proxy params + szPassword[64]=TEXT(""); + + + EXDLL_INIT(); + +// for repeating /nounload plug-un calls - global vars clean up + silent = popup = resume = nocancel = noproxy = nocookies = false; + g_ignorecertissues = false; + myFtpCommand = NULL; + openType = INTERNET_OPEN_TYPE_PRECONFIG; + status = ST_CONNECTING; + *szCaption = *szCancel = *szUserAgent = *szBasic = *szAuth = 0; + + url = (TCHAR*)LocalAlloc(LPTR, string_size * sizeof(TCHAR)); + if(szPost) + { + popstring(url); +#ifdef UNICODE + WideCharToMultiByte(CP_ACP, 0, url, -1, szPost, string_size, NULL, NULL); +#else + lstrcpy(szPost, url); +#endif + fSize = (DWORD)lstrlenA(szPost); + } + // global silent option + if(extra->exec_flags->silent != 0) + silent = true; + // we must take this from stack, or push url back + while(!popstring(url) && *url == TEXT('/')) + { + if(lstrcmpi(url, TEXT("/silent")) == 0) + silent = true; + else if(lstrcmpi(url, TEXT("/weaksecurity")) == 0) + g_ignorecertissues = true; + else if(lstrcmpi(url, TEXT("/caption")) == 0) + popstring(szCaption); + else if(lstrcmpi(url, TEXT("/username")) == 0) + popstring(szUsername); + else if(lstrcmpi(url, TEXT("/password")) == 0) + popstring(szPassword); + else if(lstrcmpi(url, TEXT("/nocancel")) == 0) + nocancel = true; + else if(lstrcmpi(url, TEXT("/nocookies")) == 0) + nocookies = true; + else if(lstrcmpi(url, TEXT("/noproxy")) == 0) + openType = INTERNET_OPEN_TYPE_DIRECT; + else if(lstrcmpi(url, TEXT("/popup")) == 0) + { + popup = true; + szAlias = (TCHAR*)LocalAlloc(LPTR, string_size * sizeof(TCHAR)); + popstring(szAlias); + } + else if(lstrcmpi(url, TEXT("/resume")) == 0) + { + popstring(url); + if(url[0]) lstrcpy(szResume, url); + resume = true; + } + else if(lstrcmpi(url, TEXT("/translate")) == 0) + { + if(popup) + { + popstring(szUrl); + popstring(szStatus[ST_DOWNLOAD]); // Downloading + popstring(szStatus[ST_CONNECTING]); // Connecting + lstrcpy(szStatus[ST_URLOPEN], szStatus[ST_CONNECTING]); + popstring(szDownloading);// file name + popstring(szConnecting);// received + popstring(szProgress);// file size + popstring(szSecond);// remaining time + popstring(szRemaining);// total time + } + else + { + popstring(szDownloading); + popstring(szConnecting); + popstring(szSecond); + popstring(szMinute); + popstring(szHour); + popstring(szPlural); + popstring(szProgress); + popstring(szRemaining); + } + } + else if(lstrcmpi(url, TEXT("/banner")) == 0) + { + popup = true; + szBanner = (TCHAR*)LocalAlloc(LPTR, string_size * sizeof(TCHAR)); + popstring(szBanner); + } + else if(lstrcmpi(url, TEXT("/canceltext")) == 0) + { + popstring(szCancel); + } + else if(lstrcmpi(url, TEXT("/question")) == 0) + { + szQuestion = (TCHAR*)LocalAlloc(LPTR, string_size * sizeof(TCHAR)); + popstring(szQuestion); + if(*szQuestion == 0) lstrcpy(szQuestion, DEF_QUESTION); + } + else if(lstrcmpi(url, TEXT("/useragent")) == 0) + { + popstring(szUserAgent); + } + else if(lstrcmpi(url, TEXT("/proxy")) == 0) + { + szProxy = (TCHAR*)LocalAlloc(LPTR, string_size * sizeof(TCHAR)); + popstring(szProxy); + openType = INTERNET_OPEN_TYPE_PROXY; + } + else if(lstrcmpi(url, TEXT("/connecttimeout")) == 0) + { + popstring(url); + timeout = myatou(url) * 1000; + } + else if(lstrcmpi(url, TEXT("/receivetimeout")) == 0) + { + popstring(url); + receivetimeout = myatou(url) * 1000; + } + else if(lstrcmpi(url, TEXT("/header")) == 0) + { + szHeader = (TCHAR*)LocalAlloc(LPTR, string_size * sizeof(TCHAR)); + popstring(szHeader); + } + else if(!fput && ((convToStack = lstrcmpi(url, TEXT("/tostackconv")) == 0) || lstrcmpi(url, TEXT("/tostack")) == 0)) + { + szToStack = (TCHAR*)LocalAlloc(LPTR, string_size * sizeof(TCHAR)); + cntToStack = 0; + lstrcpy(fn, TEXT("file")); + } + else if(lstrcmpi(url, TEXT("/file")) == 0) + { + HANDLE hFile = CreateFileA(szPost, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); + DWORD rslt; + if(hFile == INVALID_HANDLE_VALUE) + { + status = ERR_FILEOPEN; + goto cleanup; + } + if((fSize = GetFileSize(hFile, NULL)) == 0) + { + CloseHandle(hFile); + status = ERR_FILEREAD; + goto cleanup; + } + wsprintfA(post_fname, "Filename: %s", + strchr(szPost, '\\') ? strrchr(szPost, '\\') + 1 : szPost); + LocalFree(szPost); + szPost = (char*)LocalAlloc(LPTR, fSize); + if(ReadFile(hFile, szPost, fSize, &rslt, NULL) == 0 || rslt != fSize) + { + CloseHandle(hFile); + status = ERR_FILEREAD; + goto cleanup; + } + CloseHandle(hFile); + } + } + pushstring(url); +// if(*szCaption == 0) lstrcpy(szCaption, PLUGIN_NAME); + if(*szUserAgent == 0) lstrcpy(szUserAgent, INETC_USERAGENT); + if(*szPassword && *szUsername) + { + wsprintf(url, TEXT("%s:%s"), szUsername, szPassword); + encode_base64(lstrlen(url), url, szAuth); + } + // may be silent for plug-in, but not so for installer itself - let's try to define 'progress text' + if(hwndParent != NULL && + (childwnd = FindWindowEx(hwndParent, NULL, TEXT("#32770"), NULL)) != NULL && + !silent) + SetDlgItemText(childwnd, 1006, *szCaption ? szCaption : PLUGIN_NAME); + else InitCommonControls(); // or NSIS do this before .onInit? + // cannot embed child dialog to non-existing parent. Using 'silent' to hide it + if(childwnd == NULL && !popup) silent = true; + // let's use hidden popup dlg in the silent mode - works both on .onInit and Page + if(silent) { resume = false; popup = true; } + // google says WS_CLIPSIBLINGS helps to redraw... not in my tests... + if(!popup) + { + unsigned int wstyle = GetWindowLong(childwnd, GWL_STYLE); + wstyle |= WS_CLIPSIBLINGS; + SetWindowLong(childwnd, GWL_STYLE, wstyle); + } + startTime = GetTickCount(); + if((hDlg = CreateDialog(g_hInstance, + MAKEINTRESOURCE(szBanner ? IDD_DIALOG2 : (popup ? IDD_DIALOG1 : IDD_DIALOG3)), + (popup ? hwndParent : childwnd), dlgProc)) != NULL) + { + + if((hThread = CreateThread(NULL, 0, inetTransfer, (LPVOID)hDlg, 0, + &dwThreadId)) != NULL) + { + HWND hButton = GetDlgItem(childwnd, 0x403); + HWND hList = GetDlgItem(childwnd, 0x3f8); + DWORD dwStyleButton = 0; + BOOL fVisibleList = false; + if(!silent) + { + ShowWindow(hDlg, SW_NORMAL); + if(childwnd && !popup) + { + if(hButton) + { + dwStyleButton = GetWindowLong(hButton, GWL_STYLE); + EnableWindow(hButton, false); + } + if(hList) + { + fVisibleList = IsWindowVisible(hList); + ShowWindow(hList, SW_HIDE); + } + } + } + + while(IsWindow(hDlg) && + GetMessage(&msg, NULL, 0, 0) > 0) + { + if(!IsDialogMessage(hDlg, &msg) && + !IsDialogMessage(hwndParent, &msg) && + !TranslateMessage(&msg)) + DispatchMessage(&msg); + } + + if(WaitForSingleObject(hThread, 3000) == WAIT_TIMEOUT) + { + TerminateThread(hThread, 1); + status = ERR_TERMINATED; + } + CloseHandle(hThread); + if(!silent && childwnd) + { + SetDlgItemText(childwnd, 1006, TEXT("")); + if(!popup) + { + if(hButton) + SetWindowLong(hButton, GWL_STYLE, dwStyleButton); + if(hList && fVisibleList) + ShowWindow(hList, SW_SHOW); + } + // RedrawWindow(childwnd, NULL, NULL, RDW_INVALIDATE|RDW_ERASE); + } + } + else + { + status = ERR_THREAD; + DestroyWindow(hDlg); + } + } + else { + status = ERR_DIALOG; + wsprintf(szStatus[status] + lstrlen(szStatus[status]), TEXT(" (Err=%d)"), GetLastError()); + } +cleanup: + // we need to clean up stack from remaining url/file pairs. + // this multiple files download head pain and may be not safe + while(!popstring(url) && lstrcmpi(url, TEXT("/end")) != 0) + { + /* nothing MessageBox(NULL, url, TEXT(""), 0);*/ + } + LocalFree(url); + if(szAlias) LocalFree(szAlias); + if(szBanner) LocalFree(szAlias); + if(szQuestion) LocalFree(szQuestion); + if(szProxy) LocalFree(szProxy); + if(szPost) LocalFree(szPost); + if(szHeader) LocalFree(szHeader); + + url = szProxy = szHeader = szAlias = szQuestion = NULL; + szPost = NULL; + fput = fhead = false; + + if(szToStack && status == ST_OK) + { + if(cntToStack > 0 && convToStack) + { +#ifdef UNICODE + int cp = CP_ACP; + if (0xef == ((BYTE*)szToStack)[0] && 0xbb == ((BYTE*)szToStack)[1] && 0xbf == ((BYTE*)szToStack)[2]) cp = 65001; // CP_UTF8 + if (0xff == ((BYTE*)szToStack)[0] && 0xfe == ((BYTE*)szToStack)[1]) + { + cp = 1200; // UTF-16LE + pushstring((LPWSTR)szToStack); + } + int required = (cp == 1200) ? 0 : MultiByteToWideChar(cp, 0, (CHAR*)szToStack, string_size * sizeof(TCHAR), NULL, 0); + if(required > 0) + { + WCHAR* pszToStackNew = (WCHAR*)LocalAlloc(LPTR, sizeof(WCHAR) * (required + 1)); + if(pszToStackNew) + { + if(MultiByteToWideChar(cp, 0, (CHAR*)szToStack, string_size * sizeof(TCHAR), pszToStackNew, required) > 0) + pushstring(pszToStackNew); + LocalFree(pszToStackNew); + } + } +#else + int required = WideCharToMultiByte(CP_ACP, 0, (WCHAR*)szToStack, -1, NULL, 0, NULL, NULL); + if(required > 0) + { + CHAR* pszToStackNew = (CHAR*)LocalAlloc(LPTR, required + 1); + if(pszToStackNew) + { + if(WideCharToMultiByte(CP_ACP, 0, (WCHAR*)szToStack, -1, pszToStackNew, required, NULL, NULL) > 0) + pushstring(pszToStackNew); + LocalFree(pszToStackNew); + } + } +#endif + } + else + { + pushstring(szToStack); + } + LocalFree(szToStack); + szToStack = NULL; + } + + pushstring(szStatus[status]); +} + +/***************************************************** +* FUNCTION NAME: put() +* PURPOSE: +* http/ftp file upload entry point +* SPECIAL CONSIDERATIONS: +* re-put not works with http, but ftp REST - may be. +*****************************************************/ +extern "C" +void __declspec(dllexport) __cdecl put(HWND hwndParent, + int string_size, + TCHAR *variables, + stack_t **stacktop, + extra_parameters *extra + ) +{ + fput = true; + lstrcpy(szDownloading, TEXT("Uploading %s")); + lstrcpy(szStatus[2], TEXT("Uploading")); + get(hwndParent, string_size, variables, stacktop, extra); +} + +/***************************************************** +* FUNCTION NAME: post() +* PURPOSE: +* http post entry point +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +extern "C" +void __declspec(dllexport) __cdecl post(HWND hwndParent, + int string_size, + TCHAR *variables, + stack_t **stacktop, + extra_parameters *extra + ) +{ + szPost = (CHAR*)LocalAlloc(LPTR, string_size); + get(hwndParent, string_size, variables, stacktop, extra); +} + +/***************************************************** +* FUNCTION NAME: head() +* PURPOSE: +* http/ftp file upload entry point +* SPECIAL CONSIDERATIONS: +* re-put not works with http, but ftp REST - may be. +*****************************************************/ +extern "C" +void __declspec(dllexport) __cdecl head(HWND hwndParent, + int string_size, + TCHAR *variables, + stack_t **stacktop, + extra_parameters *extra + ) +{ + fhead = true; + get(hwndParent, string_size, variables, stacktop, extra); +} + +/***************************************************** +* FUNCTION NAME: DllMain() +* PURPOSE: +* Dll main (initialization) entry point +* SPECIAL CONSIDERATIONS: +* +*****************************************************/ +#ifdef _VC_NODEFAULTLIB +#define DllMain _DllMainCRTStartup +#endif +EXTERN_C BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) +{ + g_hInstance = hinstDLL; + return TRUE; +} diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.rc b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.rc new file mode 100644 index 0000000000000000000000000000000000000000..0384750c83d58e19f563c205f18d71a11edcf55c --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.rc @@ -0,0 +1,199 @@ +// Microsoft Visual C++ generated resource script. +// +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "afxres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// Russian (Russia) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_RUS) +LANGUAGE LANG_RUSSIAN, SUBLANG_DEFAULT +#pragma code_page(1251) + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""afxres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + +#endif // Russian (Russia) resources +///////////////////////////////////////////////////////////////////////////// + + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US +#pragma code_page(1252) + +///////////////////////////////////////////////////////////////////////////// +// +// Dialog +// + +IDD_DIALOG1 DIALOGEX 0, 0, 286, 71 +STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU +CAPTION "Inetc plug-in" +FONT 8, "MS Sans Serif", 0, 0, 0x1 +BEGIN + LTEXT "",IDC_STATIC1,50,4,230,12,SS_CENTERIMAGE,WS_EX_STATICEDGE + LTEXT "",IDC_STATIC2,50,18,230,12,SS_CENTERIMAGE,WS_EX_STATICEDGE + CTEXT "",IDC_STATIC3,50,32,102,12,SS_CENTERIMAGE,WS_EX_STATICEDGE + CTEXT "",IDC_STATIC4,220,32,60,12,SS_CENTERIMAGE,WS_EX_STATICEDGE + CONTROL "Progress1",IDC_PROGRESS1,"msctls_progress32",NOT WS_VISIBLE,5,62,275,7 + CTEXT "",IDC_STATIC5,50,46,102,12,SS_CENTERIMAGE,WS_EX_STATICEDGE + CTEXT "",IDC_STATIC6,220,46,60,12,SS_CENTERIMAGE,WS_EX_STATICEDGE + CONTROL "URL",IDC_STATIC20,"Static",SS_LEFTNOWORDWRAP | WS_GROUP,5,6,44,10 + CONTROL "File name",IDC_STATIC21,"Static",SS_LEFTNOWORDWRAP | WS_GROUP,5,20,44,10 + CONTROL "Transfered",IDC_STATIC22,"Static",SS_LEFTNOWORDWRAP | WS_GROUP,5,34,44,10 + CONTROL "File size",IDC_STATIC23,"Static",SS_LEFTNOWORDWRAP | WS_GROUP,5,48,44,10 + CONTROL "Remaining time",IDC_STATIC24,"Static",SS_LEFTNOWORDWRAP | WS_GROUP,164,34,55,10 + CONTROL "Total time",IDC_STATIC25,"Static",SS_LEFTNOWORDWRAP | WS_GROUP,164,48,55,10 +END + +IDD_DIALOG2 DIALOG 0, 0, 226, 62 +STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU +CAPTION "Inetc plug-in" +FONT 8, "MS Sans Serif" +BEGIN + ICON 103,IDC_STATIC13,4,4,20,20 + LTEXT "Please wait",IDC_STATIC12,35,6,184,28 + CONTROL "Progress1",IDC_PROGRESS1,"msctls_progress32",NOT WS_VISIBLE,12,40,201,11 +END + +IDD_DIALOG3 DIALOG 0, 0, 266, 62 +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_VISIBLE +FONT 8, "MS Sans Serif" +BEGIN + CONTROL "Progress1",IDC_PROGRESS1,"msctls_progress32",0x0,0,23,266,11 + CTEXT "",IDC_STATIC1,0,8,266,11 + PUSHBUTTON "Cancel",IDCANCEL,166,41,80,16 +END + + +///////////////////////////////////////////////////////////////////////////// +// +// DESIGNINFO +// + +#ifdef APSTUDIO_INVOKED +GUIDELINES DESIGNINFO +BEGIN + IDD_DIALOG1, DIALOG + BEGIN + LEFTMARGIN, 7 + RIGHTMARGIN, 279 + TOPMARGIN, 7 + BOTTOMMARGIN, 64 + END + + IDD_DIALOG2, DIALOG + BEGIN + LEFTMARGIN, 7 + RIGHTMARGIN, 219 + TOPMARGIN, 7 + BOTTOMMARGIN, 55 + END + + IDD_DIALOG3, DIALOG + BEGIN + LEFTMARGIN, 7 + RIGHTMARGIN, 259 + TOPMARGIN, 7 + BOTTOMMARGIN, 55 + END +END +#endif // APSTUDIO_INVOKED + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + +///////////////////////////////////////////////////////////////////////////// +// English (United Kingdom) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENG) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_UK +#pragma code_page(1252) + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +VS_VERSION_INFO VERSIONINFO + FILEVERSION 1,0,5,2 + PRODUCTVERSION 1,0,5,2 + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x2L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "FileDescription", "inetc NSIS plug-in" + VALUE "FileVersion", "1.0.5.2" + VALUE "InternalName", "inetc.dll" + VALUE "LegalCopyright", "Copyright Takhir Bedertdinov" + VALUE "OriginalFilename", "inetc.dll" + VALUE "ProductName", "inetc NSIS plug-in" + VALUE "ProductVersion", "1.0.5.2" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +#endif // English (United Kingdom) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED + diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.sln b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.sln new file mode 100644 index 0000000000000000000000000000000000000000..04b4db35152e73b75c3fb3e40e56eeea47c1248c --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "inetc", "inetc.vcxproj", "{6B2D8C40-38A9-457A-9FA6-BED0108CAC37}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug Unicode|Win32 = Debug Unicode|Win32 + Debug Unicode|x64 = Debug Unicode|x64 + Debug|Win32 = Debug|Win32 + Debug|x64 = Debug|x64 + Release Unicode|Win32 = Release Unicode|Win32 + Release Unicode|x64 = Release Unicode|x64 + Release|Win32 = Release|Win32 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug Unicode|Win32.ActiveCfg = Debug Unicode|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug Unicode|Win32.Build.0 = Debug Unicode|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug Unicode|Win32.Deploy.0 = Debug Unicode|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug Unicode|x64.ActiveCfg = Debug Unicode|x64 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug Unicode|x64.Build.0 = Debug Unicode|x64 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug|Win32.ActiveCfg = Debug|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug|Win32.Build.0 = Debug|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug|Win32.Deploy.0 = Debug|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug|x64.ActiveCfg = Debug|x64 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Debug|x64.Build.0 = Debug|x64 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release Unicode|Win32.ActiveCfg = Release Unicode|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release Unicode|Win32.Build.0 = Release Unicode|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release Unicode|Win32.Deploy.0 = Release Unicode|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release Unicode|x64.ActiveCfg = Release Unicode|x64 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release Unicode|x64.Build.0 = Release Unicode|x64 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release|Win32.ActiveCfg = Release|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release|Win32.Build.0 = Release|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release|Win32.Deploy.0 = Release|Win32 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release|x64.ActiveCfg = Release|x64 + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.vcxproj b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.vcxproj new file mode 100644 index 0000000000000000000000000000000000000000..9829ec0b4f1aa618c0f38603f3ff99bdae63db54 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.vcxproj @@ -0,0 +1,441 @@ + + + + + Debug Unicode + Win32 + + + Debug Unicode + x64 + + + Debug + Win32 + + + Debug + x64 + + + Release Unicode + Win32 + + + Release Unicode + x64 + + + Release + Win32 + + + Release + x64 + + + + + + {6B2D8C40-38A9-457A-9FA6-BED0108CAC37} + + + + DynamicLibrary + v110 + Unicode + + + DynamicLibrary + v110 + Unicode + + + DynamicLibrary + v110 + MultiByte + + + DynamicLibrary + v110 + MultiByte + + + DynamicLibrary + v110 + Unicode + + + DynamicLibrary + v110 + Unicode + + + DynamicLibrary + v110 + MultiByte + + + DynamicLibrary + v110 + MultiByte + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + $(SolutionDir)..\..\Plugins\ + + + false + $(ProjectName)64 + $(SolutionDir)..\..\Plugins\ + + + $(SolutionDir)..\..\Unicode\Plugins\ + true + false + + + true + false + $(ProjectName)64 + + + $(SolutionDir)..\..\Plugins\ + false + + + false + $(ProjectName)64 + + + false + $(SolutionDir)..\..\Unicode\Plugins\ + + + false + $(ProjectName)64 + $(SolutionDir)..\..\Unicode\Plugins\ + + + + MultiThreaded + MinSpace + true + Level3 + WIN32;NDEBUG;_WINDOWS;_USRDLL;inetc_EXPORTS;_VC80_UPGRADE=0x0600;%(PreprocessorDefinitions) + false + + + true + NDEBUG;%(PreprocessorDefinitions) + .\debug\inetc.tlb + true + Win32 + + + 0x0409 + NDEBUG;%(PreprocessorDefinitions) + + + true + .\debug\inetc.bsc + + + true + true + wininet.lib;comctl32.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies) + false + DllMain + libc.lib + true + + + + + MultiThreaded + MinSpace + true + Level3 + WIN32;NDEBUG;_WINDOWS;_USRDLL;inetc_EXPORTS;_VC80_UPGRADE=0x0600;%(PreprocessorDefinitions) + false + + + true + NDEBUG;%(PreprocessorDefinitions) + .\debug\inetc.tlb + true + + + 0x0409 + NDEBUG;%(PreprocessorDefinitions) + + + true + .\debug\inetc.bsc + + + true + true + true + wininet.lib;comctl32.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies) + false + libc.lib + DllMain + + + + + MultiThreadedDebug + Disabled + true + Level3 + EditAndContinue + EnableFastChecks + WIN32;_DEBUG;_WINDOWS;_USRDLL;inetc_EXPORTS;_VC80_UPGRADE=0x0600;%(PreprocessorDefinitions) + false + + + true + _DEBUG;%(PreprocessorDefinitions) + .\Debug_Unicode\inetc.tlb + true + Win32 + + + 0x0409 + _DEBUG;%(PreprocessorDefinitions) + + + true + .\Debug_Unicode\inetc.bsc + + + true + true + .\Debug_Unicode\inetc.lib + wininet.lib;comctl32.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies) + false + + + + + MultiThreadedDebug + Disabled + true + Level3 + ProgramDatabase + EnableFastChecks + WIN32;_DEBUG;_WINDOWS;_USRDLL;inetc_EXPORTS;_VC80_UPGRADE=0x0600;%(PreprocessorDefinitions) + false + + + true + _DEBUG;%(PreprocessorDefinitions) + .\Debug_Unicode\inetc.tlb + true + + + 0x0409 + _DEBUG;%(PreprocessorDefinitions) + + + true + .\Debug_Unicode\inetc.bsc + + + true + true + .\Debug_Unicode\inetc.lib + wininet.lib;comctl32.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies) + false + + + + + MultiThreadedDebug + Disabled + true + Level3 + EditAndContinue + WIN32;_DEBUG;_WINDOWS;_USRDLL;inetc_EXPORTS;_VC80_UPGRADE=0x0600;%(PreprocessorDefinitions) + EnableFastChecks + + + true + _DEBUG;%(PreprocessorDefinitions) + .\Debug\inetc.tlb + true + Win32 + + + 0x0409 + _DEBUG;%(PreprocessorDefinitions) + + + true + .\Debug\inetc.bsc + + + true + true + true + wininet.lib;comctl32.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies) + false + + + + + MultiThreadedDebug + Disabled + true + Level3 + ProgramDatabase + WIN32;_DEBUG;_WINDOWS;_USRDLL;inetc_EXPORTS;_VC80_UPGRADE=0x0600;%(PreprocessorDefinitions) + EnableFastChecks + + + true + _DEBUG;%(PreprocessorDefinitions) + .\Debug\inetc.tlb + true + + + 0x0409 + _DEBUG;%(PreprocessorDefinitions) + + + true + .\Debug\inetc.bsc + + + true + true + true + wininet.lib;comctl32.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies) + false + + + + + MultiThreaded + MinSpace + true + Level3 + WIN32;NDEBUG;_WINDOWS;UNICODE;_USRDLL;inetc_EXPORTS;_VC80_UPGRADE=0x0600;%(PreprocessorDefinitions) + false + + + true + NDEBUG;%(PreprocessorDefinitions) + .\Debug_Unicode\inetc.tlb + true + Win32 + + + 0x0409 + NDEBUG;%(PreprocessorDefinitions) + + + true + .\Debug_Unicode\inetc.bsc + + + true + true + wininet.lib;comctl32.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies) + true + false + DllMain + libc.lib + + + + + MultiThreaded + MinSpace + true + Level3 + WIN32;NDEBUG;_WINDOWS;UNICODE;_USRDLL;inetc_EXPORTS;_VC80_UPGRADE=0x0600;%(PreprocessorDefinitions) + false + + + true + NDEBUG;%(PreprocessorDefinitions) + .\Debug_Unicode\inetc.tlb + true + + + 0x0409 + NDEBUG;%(PreprocessorDefinitions) + + + true + .\Debug_Unicode\inetc.bsc + + + true + true + wininet.lib;comctl32.lib;odbc32.lib;odbccp32.lib;%(AdditionalDependencies) + true + false + libc.lib + DllMain + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.vcxproj.filters b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.vcxproj.filters new file mode 100644 index 0000000000000000000000000000000000000000..2020d89474ea772a78c5afc93e01dfa0a0f187a6 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/inetc.vcxproj.filters @@ -0,0 +1,52 @@ + + + + + {7803dcf0-655c-4f71-89dd-7fd695066a28} + cpp;c;cxx;rc;def;r;odl;idl;hpj;bat + + + {8edac42d-b9b9-469f-9864-3dbc65bf4365} + ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe + + + {67a938b1-f16b-478b-81df-d0fd26de6d8a} + h;hpp;hxx;hm;inl + + + + + Source Files + + + Source Files + + + Source Files + + + + + Resource Files + + + + + Resource Files + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + \ No newline at end of file diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/nsis_tchar.h b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/nsis_tchar.h new file mode 100644 index 0000000000000000000000000000000000000000..210bab16d585ae4f15b76924cabed13941cbea55 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/nsis_tchar.h @@ -0,0 +1,229 @@ +/* + * nsis_tchar.h + * + * This file is a part of NSIS. + * + * Copyright (C) 1999-2013 Nullsoft and Contributors + * + * This software is provided 'as-is', without any express or implied + * warranty. + * + * For Unicode support by Jim Park -- 08/30/2007 + */ + +// Jim Park: Only those we use are listed here. + +#pragma once + +#ifdef _UNICODE + +#ifndef _T +#define __T(x) L ## x +#define _T(x) __T(x) +#define _TEXT(x) __T(x) +#endif + +#ifndef _TCHAR_DEFINED +#define _TCHAR_DEFINED +#if !defined(_NATIVE_WCHAR_T_DEFINED) && !defined(_WCHAR_T_DEFINED) +typedef unsigned short TCHAR; +#else +typedef wchar_t TCHAR; +#endif +#endif + + +// program +#define _tenviron _wenviron +#define __targv __wargv + +// printfs +#define _ftprintf fwprintf +#define _sntprintf _snwprintf +#if (defined(_MSC_VER) && (_MSC_VER<=1310)) || defined(__MINGW32__) +# define _stprintf swprintf +#else +# define _stprintf _swprintf +#endif +#define _tprintf wprintf +#define _vftprintf vfwprintf +#define _vsntprintf _vsnwprintf +#if defined(_MSC_VER) && (_MSC_VER<=1310) +# define _vstprintf vswprintf +#else +# define _vstprintf _vswprintf +#endif + +// scanfs +#define _tscanf wscanf +#define _stscanf swscanf + +// string manipulations +#define _tcscat wcscat +#define _tcschr wcschr +#define _tcsclen wcslen +#define _tcscpy wcscpy +#define _tcsdup _wcsdup +#define _tcslen wcslen +#define _tcsnccpy wcsncpy +#define _tcsncpy wcsncpy +#define _tcsrchr wcsrchr +#define _tcsstr wcsstr +#define _tcstok wcstok + +// string comparisons +#define _tcscmp wcscmp +#define _tcsicmp _wcsicmp +#define _tcsncicmp _wcsnicmp +#define _tcsncmp wcsncmp +#define _tcsnicmp _wcsnicmp + +// upper / lower +#define _tcslwr _wcslwr +#define _tcsupr _wcsupr +#define _totlower towlower +#define _totupper towupper + +// conversions to numbers +#define _tcstoi64 _wcstoi64 +#define _tcstol wcstol +#define _tcstoul wcstoul +#define _tstof _wtof +#define _tstoi _wtoi +#define _tstoi64 _wtoi64 +#define _ttoi _wtoi +#define _ttoi64 _wtoi64 +#define _ttol _wtol + +// conversion from numbers to strings +#define _itot _itow +#define _ltot _ltow +#define _i64tot _i64tow +#define _ui64tot _ui64tow + +// file manipulations +#define _tfopen _wfopen +#define _topen _wopen +#define _tremove _wremove +#define _tunlink _wunlink + +// reading and writing to i/o +#define _fgettc fgetwc +#define _fgetts fgetws +#define _fputts fputws +#define _gettchar getwchar + +// directory +#define _tchdir _wchdir + +// environment +#define _tgetenv _wgetenv +#define _tsystem _wsystem + +// time +#define _tcsftime wcsftime + +#else // ANSI + +#ifndef _T +#define _T(x) x +#define _TEXT(x) x +#endif + +#ifndef _TCHAR_DEFINED +#define _TCHAR_DEFINED +typedef char TCHAR; +#endif + +// program +#define _tenviron environ +#define __targv __argv + +// printfs +#define _ftprintf fprintf +#define _sntprintf _snprintf +#define _stprintf sprintf +#define _tprintf printf +#define _vftprintf vfprintf +#define _vsntprintf _vsnprintf +#define _vstprintf vsprintf + +// scanfs +#define _tscanf scanf +#define _stscanf sscanf + +// string manipulations +#define _tcscat strcat +#define _tcschr strchr +#define _tcsclen strlen +#define _tcscnlen strnlen +#define _tcscpy strcpy +#define _tcsdup _strdup +#define _tcslen strlen +#define _tcsnccpy strncpy +#define _tcsrchr strrchr +#define _tcsstr strstr +#define _tcstok strtok + +// string comparisons +#define _tcscmp strcmp +#define _tcsicmp _stricmp +#define _tcsncmp strncmp +#define _tcsncicmp _strnicmp +#define _tcsnicmp _strnicmp + +// upper / lower +#define _tcslwr _strlwr +#define _tcsupr _strupr + +#define _totupper toupper +#define _totlower tolower + +// conversions to numbers +#define _tcstol strtol +#define _tcstoul strtoul +#define _tstof atof +#define _tstoi atoi +#define _tstoi64 _atoi64 +#define _tstoi64 _atoi64 +#define _ttoi atoi +#define _ttoi64 _atoi64 +#define _ttol atol + +// conversion from numbers to strings +#define _i64tot _i64toa +#define _itot _itoa +#define _ltot _ltoa +#define _ui64tot _ui64toa + +// file manipulations +#define _tfopen fopen +#define _topen _open +#define _tremove remove +#define _tunlink _unlink + +// reading and writing to i/o +#define _fgettc fgetc +#define _fgetts fgets +#define _fputts fputs +#define _gettchar getchar + +// directory +#define _tchdir _chdir + +// environment +#define _tgetenv getenv +#define _tsystem system + +// time +#define _tcsftime strftime + +#endif + +// is functions (the same in Unicode / ANSI) +#define _istgraph isgraph +#define _istascii __isascii + +#define __TFILE__ _T(__FILE__) +#define __TDATE__ _T(__DATE__) +#define __TTIME__ _T(__TIME__) diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/pluginapi.c b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/pluginapi.c new file mode 100644 index 0000000000000000000000000000000000000000..c507e31a29c411c3a33b44b1191eba2ae51eb0a9 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/pluginapi.c @@ -0,0 +1,294 @@ +#include + +#include "pluginapi.h" + +#ifdef _countof +#define COUNTOF _countof +#else +#define COUNTOF(a) (sizeof(a)/sizeof(a[0])) +#endif + +unsigned int g_stringsize; +stack_t **g_stacktop; +TCHAR *g_variables; + +// utility functions (not required but often useful) + +int NSISCALL popstring(TCHAR *str) +{ + stack_t *th; + if (!g_stacktop || !*g_stacktop) return 1; + th=(*g_stacktop); + if (str) lstrcpy(str,th->text); + *g_stacktop = th->next; + GlobalFree((HGLOBAL)th); + return 0; +} + +int NSISCALL popstringn(TCHAR *str, int maxlen) +{ + stack_t *th; + if (!g_stacktop || !*g_stacktop) return 1; + th=(*g_stacktop); + if (str) lstrcpyn(str,th->text,maxlen?maxlen:g_stringsize); + *g_stacktop = th->next; + GlobalFree((HGLOBAL)th); + return 0; +} + +void NSISCALL pushstring(const TCHAR *str) +{ + stack_t *th; + if (!g_stacktop) return; + th=(stack_t*)GlobalAlloc(GPTR,(sizeof(stack_t)+(g_stringsize)*sizeof(TCHAR))); + lstrcpyn(th->text,str,g_stringsize); + th->next=*g_stacktop; + *g_stacktop=th; +} + +TCHAR* NSISCALL getuservariable(const int varnum) +{ + if (varnum < 0 || varnum >= __INST_LAST) return NULL; + return g_variables+varnum*g_stringsize; +} + +void NSISCALL setuservariable(const int varnum, const TCHAR *var) +{ + if (var != NULL && varnum >= 0 && varnum < __INST_LAST) + lstrcpy(g_variables + varnum*g_stringsize, var); +} + +#ifdef _UNICODE +int NSISCALL PopStringA(char* ansiStr) +{ + wchar_t* wideStr = (wchar_t*) GlobalAlloc(GPTR, g_stringsize*sizeof(wchar_t)); + int rval = popstring(wideStr); + WideCharToMultiByte(CP_ACP, 0, wideStr, -1, ansiStr, g_stringsize, NULL, NULL); + GlobalFree((HGLOBAL)wideStr); + return rval; +} + +int NSISCALL PopStringNA(char* ansiStr, int maxlen) +{ + int realLen = maxlen ? maxlen : g_stringsize; + wchar_t* wideStr = (wchar_t*) GlobalAlloc(GPTR, realLen*sizeof(wchar_t)); + int rval = popstringn(wideStr, realLen); + WideCharToMultiByte(CP_ACP, 0, wideStr, -1, ansiStr, realLen, NULL, NULL); + GlobalFree((HGLOBAL)wideStr); + return rval; +} + +void NSISCALL PushStringA(const char* ansiStr) +{ + wchar_t* wideStr = (wchar_t*) GlobalAlloc(GPTR, g_stringsize*sizeof(wchar_t)); + MultiByteToWideChar(CP_ACP, 0, ansiStr, -1, wideStr, g_stringsize); + pushstring(wideStr); + GlobalFree((HGLOBAL)wideStr); + return; +} + +void NSISCALL GetUserVariableW(const int varnum, wchar_t* wideStr) +{ + lstrcpyW(wideStr, getuservariable(varnum)); +} + +void NSISCALL GetUserVariableA(const int varnum, char* ansiStr) +{ + wchar_t* wideStr = getuservariable(varnum); + WideCharToMultiByte(CP_ACP, 0, wideStr, -1, ansiStr, g_stringsize, NULL, NULL); +} + +void NSISCALL SetUserVariableA(const int varnum, const char* ansiStr) +{ + if (ansiStr != NULL && varnum >= 0 && varnum < __INST_LAST) + { + wchar_t* wideStr = g_variables + varnum * g_stringsize; + MultiByteToWideChar(CP_ACP, 0, ansiStr, -1, wideStr, g_stringsize); + } +} + +#else +// ANSI defs +int NSISCALL PopStringW(wchar_t* wideStr) +{ + char* ansiStr = (char*) GlobalAlloc(GPTR, g_stringsize); + int rval = popstring(ansiStr); + MultiByteToWideChar(CP_ACP, 0, ansiStr, -1, wideStr, g_stringsize); + GlobalFree((HGLOBAL)ansiStr); + return rval; +} + +int NSISCALL PopStringNW(wchar_t* wideStr, int maxlen) +{ + int realLen = maxlen ? maxlen : g_stringsize; + char* ansiStr = (char*) GlobalAlloc(GPTR, realLen); + int rval = popstringn(ansiStr, realLen); + MultiByteToWideChar(CP_ACP, 0, ansiStr, -1, wideStr, realLen); + GlobalFree((HGLOBAL)ansiStr); + return rval; +} + +void NSISCALL PushStringW(wchar_t* wideStr) +{ + char* ansiStr = (char*) GlobalAlloc(GPTR, g_stringsize); + WideCharToMultiByte(CP_ACP, 0, wideStr, -1, ansiStr, g_stringsize, NULL, NULL); + pushstring(ansiStr); + GlobalFree((HGLOBAL)ansiStr); +} + +void NSISCALL GetUserVariableW(const int varnum, wchar_t* wideStr) +{ + char* ansiStr = getuservariable(varnum); + MultiByteToWideChar(CP_ACP, 0, ansiStr, -1, wideStr, g_stringsize); +} + +void NSISCALL GetUserVariableA(const int varnum, char* ansiStr) +{ + lstrcpyA(ansiStr, getuservariable(varnum)); +} + +void NSISCALL SetUserVariableW(const int varnum, const wchar_t* wideStr) +{ + if (wideStr != NULL && varnum >= 0 && varnum < __INST_LAST) + { + char* ansiStr = g_variables + varnum * g_stringsize; + WideCharToMultiByte(CP_ACP, 0, wideStr, -1, ansiStr, g_stringsize, NULL, NULL); + } +} +#endif + +// playing with integers + +INT_PTR NSISCALL nsishelper_str_to_ptr(const TCHAR *s) +{ + INT_PTR v=0; + if (*s == _T('0') && (s[1] == _T('x') || s[1] == _T('X'))) + { + s++; + for (;;) + { + int c=*(++s); + if (c >= _T('0') && c <= _T('9')) c-=_T('0'); + else if (c >= _T('a') && c <= _T('f')) c-=_T('a')-10; + else if (c >= _T('A') && c <= _T('F')) c-=_T('A')-10; + else break; + v<<=4; + v+=c; + } + } + else if (*s == _T('0') && s[1] <= _T('7') && s[1] >= _T('0')) + { + for (;;) + { + int c=*(++s); + if (c >= _T('0') && c <= _T('7')) c-=_T('0'); + else break; + v<<=3; + v+=c; + } + } + else + { + int sign=0; + if (*s == _T('-')) sign++; else s--; + for (;;) + { + int c=*(++s) - _T('0'); + if (c < 0 || c > 9) break; + v*=10; + v+=c; + } + if (sign) v = -v; + } + + return v; +} + +unsigned int NSISCALL myatou(const TCHAR *s) +{ + unsigned int v=0; + + for (;;) + { + unsigned int c=*s++; + if (c >= _T('0') && c <= _T('9')) c-=_T('0'); + else break; + v*=10; + v+=c; + } + return v; +} + +int NSISCALL myatoi_or(const TCHAR *s) +{ + int v=0; + if (*s == _T('0') && (s[1] == _T('x') || s[1] == _T('X'))) + { + s++; + for (;;) + { + int c=*(++s); + if (c >= _T('0') && c <= _T('9')) c-=_T('0'); + else if (c >= _T('a') && c <= _T('f')) c-=_T('a')-10; + else if (c >= _T('A') && c <= _T('F')) c-=_T('A')-10; + else break; + v<<=4; + v+=c; + } + } + else if (*s == _T('0') && s[1] <= _T('7') && s[1] >= _T('0')) + { + for (;;) + { + int c=*(++s); + if (c >= _T('0') && c <= _T('7')) c-=_T('0'); + else break; + v<<=3; + v+=c; + } + } + else + { + int sign=0; + if (*s == _T('-')) sign++; else s--; + for (;;) + { + int c=*(++s) - _T('0'); + if (c < 0 || c > 9) break; + v*=10; + v+=c; + } + if (sign) v = -v; + } + + // Support for simple ORed expressions + if (*s == _T('|')) + { + v |= myatoi_or(s+1); + } + + return v; +} + +INT_PTR NSISCALL popintptr() +{ + TCHAR buf[128]; + if (popstringn(buf,COUNTOF(buf))) + return 0; + return nsishelper_str_to_ptr(buf); +} + +int NSISCALL popint_or() +{ + TCHAR buf[128]; + if (popstringn(buf,COUNTOF(buf))) + return 0; + return myatoi_or(buf); +} + +void NSISCALL pushintptr(INT_PTR value) +{ + TCHAR buffer[30]; + wsprintf(buffer, sizeof(void*) > 4 ? _T("%Id") : _T("%d"), value); + pushstring(buffer); +} diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/pluginapi.h b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/pluginapi.h new file mode 100644 index 0000000000000000000000000000000000000000..ca671a8edac49b02900258f43c54fc463c6874af --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/pluginapi.h @@ -0,0 +1,104 @@ +#ifndef ___NSIS_PLUGIN__H___ +#define ___NSIS_PLUGIN__H___ + +#ifdef __cplusplus +extern "C" { +#endif + +#include "api.h" +#include "nsis_tchar.h" + +#ifndef NSISCALL +# define NSISCALL __stdcall +#endif + +#define EXDLL_INIT() { \ + g_stringsize=string_size; \ + g_stacktop=stacktop; \ + g_variables=variables; } + +typedef struct _stack_t { + struct _stack_t *next; + TCHAR text[1]; // this should be the length of string_size +} stack_t; + +enum +{ +INST_0, // $0 +INST_1, // $1 +INST_2, // $2 +INST_3, // $3 +INST_4, // $4 +INST_5, // $5 +INST_6, // $6 +INST_7, // $7 +INST_8, // $8 +INST_9, // $9 +INST_R0, // $R0 +INST_R1, // $R1 +INST_R2, // $R2 +INST_R3, // $R3 +INST_R4, // $R4 +INST_R5, // $R5 +INST_R6, // $R6 +INST_R7, // $R7 +INST_R8, // $R8 +INST_R9, // $R9 +INST_CMDLINE, // $CMDLINE +INST_INSTDIR, // $INSTDIR +INST_OUTDIR, // $OUTDIR +INST_EXEDIR, // $EXEDIR +INST_LANG, // $LANGUAGE +__INST_LAST +}; + +extern unsigned int g_stringsize; +extern stack_t **g_stacktop; +extern TCHAR *g_variables; + +void NSISCALL pushstring(const TCHAR *str); +void NSISCALL pushintptr(INT_PTR value); +#define pushint(v) pushintptr((INT_PTR)(v)) +int NSISCALL popstring(TCHAR *str); // 0 on success, 1 on empty stack +int NSISCALL popstringn(TCHAR *str, int maxlen); // with length limit, pass 0 for g_stringsize +INT_PTR NSISCALL popintptr(); +#define popint() ( (int) popintptr() ) +int NSISCALL popint_or(); // with support for or'ing (2|4|8) +INT_PTR NSISCALL nsishelper_str_to_ptr(const TCHAR *s); +#define myatoi(s) ( (int) nsishelper_str_to_ptr(s) ) // converts a string to an integer +unsigned int NSISCALL myatou(const TCHAR *s); // converts a string to an unsigned integer, decimal only +int NSISCALL myatoi_or(const TCHAR *s); // with support for or'ing (2|4|8) +TCHAR* NSISCALL getuservariable(const int varnum); +void NSISCALL setuservariable(const int varnum, const TCHAR *var); + +#ifdef _UNICODE +#define PopStringW(x) popstring(x) +#define PushStringW(x) pushstring(x) +#define SetUserVariableW(x,y) setuservariable(x,y) + +int NSISCALL PopStringA(char* ansiStr); +void NSISCALL PushStringA(const char* ansiStr); +void NSISCALL GetUserVariableW(const int varnum, wchar_t* wideStr); +void NSISCALL GetUserVariableA(const int varnum, char* ansiStr); +void NSISCALL SetUserVariableA(const int varnum, const char* ansiStr); + +#else +// ANSI defs + +#define PopStringA(x) popstring(x) +#define PushStringA(x) pushstring(x) +#define SetUserVariableA(x,y) setuservariable(x,y) + +int NSISCALL PopStringW(wchar_t* wideStr); +void NSISCALL PushStringW(wchar_t* wideStr); +void NSISCALL GetUserVariableW(const int varnum, wchar_t* wideStr); +void NSISCALL GetUserVariableA(const int varnum, char* ansiStr); +void NSISCALL SetUserVariableW(const int varnum, const wchar_t* wideStr); + +#endif + +#ifdef __cplusplus +} +#endif + +#endif//!___NSIS_PLUGIN__H___ diff --git a/build/WINDOWS/Plugins/inetc/Contrib/Inetc/resource.h b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/resource.h new file mode 100644 index 0000000000000000000000000000000000000000..17d43c34905820ab12fc79128dbad61f765ff120 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Contrib/Inetc/resource.h @@ -0,0 +1,47 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Developer Studio generated include file. +// Used by inetc.rc +// +#define IDC_SLOGIN 8 +#define IDC_PROGRESS 10 +#define IDC_SUBTEXT 11 +#define IDC_SPWD 11 +#define IDC_ICON1 12 +#define IDD_DIALOG1 101 +#define IDI_ICON1 102 +#define IDI_ICON2 103 +#define IDD_AUTH 104 +#define IDI_ICON3 105 +#define IDI_ICON4 106 +#define IDI_ICON5 107 +#define IDD_DIALOG2 108 +#define IDI_ICON6 109 +#define IDD_DIALOG3 110 +#define IDC_STATIC1 1001 +#define IDC_STATIC2 1002 +#define IDC_STATIC3 1003 +#define IDC_STATIC4 1004 +#define IDC_PROGRESS1 1005 +#define IDC_STATIC5 1006 +#define IDC_STATIC6 1007 +#define IDC_STATIC12 1008 +#define IDC_STATIC13 1009 +#define IDC_STATIC20 1009 +#define IDC_STATIC21 1010 +#define IDC_STATIC22 1011 +#define IDC_STATIC23 1012 +#define IDC_STATIC24 1013 +#define IDC_STATIC25 1014 +#define IDC_ELOGIN 1015 +#define IDC_EPWD 1016 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 111 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1018 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/build/WINDOWS/Plugins/inetc/Docs/Inetc/Readme.htm b/build/WINDOWS/Plugins/inetc/Docs/Inetc/Readme.htm new file mode 100644 index 0000000000000000000000000000000000000000..0913e7425abcd212af4382a4a433c3748cbe8938 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Docs/Inetc/Readme.htm @@ -0,0 +1,153 @@ +
+

Contents

+
    +
  • 1 Links +
  • 2 Description +
  • 3 Commands +
      +
    • 3.1 get +
    • 3.2 post +
    • 3.3 head +
    • 3.4 put +
    +
  • 4 Examples +
  • 5 Credits +
+
+ +

Links

+Download: http://nsis.sourceforge.net/Inetc_plug-in + +

Description

+Internet client plug-in for files download and upload. Based on the InetLoad plug-in. +Network implementation uses MS WinInet API, supports http/https and ftp protocols. +Plugin has better proxy support compare to NSISdl plug-in. Command line may include +few URL/File pairs to be transfered. If server or proxy login/password are not setten in the script, +displays IE-style authentication dialog (except silent mode). Plug-in supports 3 +"transfer in progress" display modes: +
    +
  • old NSISdl style - additional embedded progress bar and text on the INSTFILES page; +
  • POPUP dialog mode with detailed info; +
  • BANNER mode with simple popup window. +
+Plug-in recognizes Installer's Silent mode and this case hides any output (this feature +requires NSIS 2.03 or later). Program implements simple re-get functionality - host +reconnect and download from current position after short pause. While program depends on IE settings, +it changes current IE mode to online. NSISdl code fragment was used for progress bar displaying +in the "old style" mode. For ftp use "host/path" for file location relative to user's home dir and +"host//path" for absolute path. + +

Commands

+ +Plug-in DLL functions (entry points): get, post, head, put + +

get

+ +inetc::get [/PROXY IP:PORT] [/USERNAME PROXY_LOGIN /PASSWORD PROXY_PASSWD] [/NOCOOKIES] + [/NOPROXY] [/NOCANCEL] [/CONNECTTIMEOUT TO_SEC] [/RECEIVETIMEOUT TO_SEC] [/SILENT] [/WEAKSECURITY] + [/CAPTION TEXT] [/NOCOOKIES] [/RESUME RETRY_QUESTION] [/POPUP HOST_ALIAS | /BANNER TEXT] + [/CANCELTEXT CANCEL_TEXT] [/QUESTION CANCEL_QUESTION] [/USERAGENT USER_AGENT_TEXT] + [/HEADER HEADER_TEXT] [/TRANSLATE LANG_PARAMS] [/TOSTACK | /TOSTACKCONV] + URL1 local_file1 [URL2 local_file2 [...]] [/END] +

This call returns "OK" string if successful, error description string if failed (see included InetLoad.cpp file for a full set of status strings). Usage and result processing samples are included to the package. +

/PROXY - +Overwrites current proxy settings, not required in most cases. IE settings will be used by default. +

/USERNAME - +Proxy username (http only). +

/PASSWORD - +Proxy password (http only). For server (http/ftp) authentication it is possible to use URL encoded name and password, for example http://username:password@nsis.sourceforge.net. +

/NOPROXY - +Disables proxy settings for this connection (if any) +

/NOCANCEL - +Prevents download from being interrupted by user (locks Esc, Alt-F4, Cancel handling, removes sysmenu) +

/CONNECTTIMEOUT - +Sets INTERNET_OPTION_CONNECT_TIMEOUT, seconds, default - IE current parameter value. +

/RECEIVETIMEOUT - +Sets INTERNET_OPTION_RECEIVE_TIMEOUT, seconds, default - IE current parameter value. +

/SILENT - +Key hides plug-in' output (both popup dialog and embedded progress bar). Not required if 'SilentInstall silent' mode was defined in script (NSIS 2.03 or later). +

/WEAKSECURITY - +Ignore unknown and revoked certificates +

/RESUME - +On the permanent connection/transfer error instead of exit first displays message box with "resume download" question. Useful for dial-up connections and big files - allows user to restore connection and resume download. Default is "Your internet connection seems to have dropped out!\nPlease reconnect and click Retry to resume downloading...". +

/CAPTION - +Defines caption text for /BANNER mode, caption prefix (left of '-') for /POPUP mode and caption for RESUME MessageBox. Default is "InetLoad plug-in" if not set or "". 127 chars maximum. +

/POPUP - +This mode displays detailed download dialog instead of embedded progress bar. Also useful in .onInit function (i.e. not in Section). If HOST_ALIAS is not "", text will replace URL in the dialog - this allows to hide real URL (including password). +

/BANNER - +Displays simple popup dialog (MSI Banner mode) and sets dialog TEXT (up to 3 lines using $\n). +

/CANCELTEXT - +Text for the Cancel button in the NSISdl mode. Default is NSIS dialog Cancel button text (current lang). +

/QUESTION - +Text for the optional MessageBox if user tries to cancel download. If /QUESTION "" was used default +"Are you sure that you want to stop download?" will be substituted. +

/USERAGENT - +UserAgent http request header value. Default is "NSIS_Inetc (Mozilla)". 256 chars maximum. +

/HEADER - +Adds or replaces http request header. Common HEADER_TEXT format is "header: value". +

/NOCOOKIES - +Removes cookies from http request +

/TOSTACK - +Outputs the post/get/head response to the NSIS stack rather than to the file (specify an empty string for local_file1, local_file2 etc.) +

/TOSTACKCONV - +Outputs the post/get/head response to the NSIS stack while converting character encodings:
+ASCII -> Unicode for the Unicode plug-in build
+Unicode -> ASCII for the ASCII plug-in build +

/END - +Allows to limit plug-in stack reading (optional, required if you stores other vars in the stack). +

/TRANSLATE - +Allows translating plug-in text in the POPUP or NSISdl modes. 8 parameters both cases.
+ +NSISdl mode parameters:
+ /TRANSLATE downloading connecting second minute hour plural progress remaining
+With default values:
+ "Downloading %s" "Connecting ..." second minute hour s "%dkB (%d%%) of %dkB @ %d.%01dkB/s" "(%d %s%s remaining)"
+ +POPUP mode parameters:
+ /TRANSLATE url downloading connecting file_name received file_size remaining_time total_time
+With default values:
+ URL Downloading Connecting "File Name" Received "File Size" "Remaining Time" "Total Time"
+ +

post

+ +inetc::post TEXT2POST [/FILE] [/PROXY IP:PORT] [/NOPROXY] [/NOCANCEL] + [/USERNAME PROXY_LOGIN /PASSWORD PROXY_PASSWD] [/TIMEOUT INT_MS] [/SILENT] [/WEAKSECURITY] + [/CAPTION TEXT] [/POPUP | /BANNER TEXT] [/CANCELTEXT CANCEL_TEXT] + [/USERAGENT USER_AGENT_TEXT] [/TRANSLATE LANG_PARAMS] [/TOSTACK | /TOSTACKCONV] + URL1 local_file1 [URL2 local_file2 [...]] [/END] +

Sets POST http mode and defines text string to be used in the POST (http only). Disables auto re-get. No char replaces used (%20 and others). +If /FILE presents in command line, TEXT2POST is filename to be sent in POST request. Also 'Filename:' header will be added to HTTP headers. + +

head

+ +The same as get, but requests http headers only. Writes raw headers to file. + +

put

+ +inetc::put [/PROXY IP:PORT] [/USERNAME PROXY_LOGIN /PASSWORD PROXY_PASSWD] [/NOPROXY] + [/NOCANCEL] [/TIMEOUT INT_MS] [/SILENT] [/WEAKSECURITY] [/CAPTION TEXT] [/POPUP | /BANNER TEXT] + [/CANCELTEXT CANCEL_TEXT] [/USERAGENT USER_AGENT_TEXT] + [/TRANSLATE LANG_PARAMS] URL1 local_file1 [URL2 local_file2 [...]] [/END] +

Return value and parameters (if applicable) are the same as for previous entry point. + +

Examples

+
  inetc::put "http://dl.zvuki.ru/6306/mp3/12.mp3" "$EXEDIR\12.mp3" \
+     "ftp://dl.zvuki.ru/6306/mp3/11.mp3" "$EXEDIR\11.mp3"
+  Pop $0
+  inetc::put /BANNER "Cameron Diaz upload in progress..." \
+    "http://www.dreamgirlswallpaper.co.uk/fiveyearsonline/wallpaper/Cameron_Diaz/camerond09big.JPG" \
+    "$EXEDIR\cd.jpg"
+  Pop $0
+  StrCmp $0 "OK" dlok
+  MessageBox MB_OK|MB_ICONEXCLAMATION "http upload Error, click OK to abort installation" /SD IDOK
+  Abort
+dlok:
+  ...
+ +

Credits

+

Many thanks to Backland who offered a simple way to fix NSISdl mode crashes, added 'center parent' function, offers few nice design ideas and spent a lot of time testing the plug-in.

+
+

v1.0.4.4 by Stuart 'Afrow UK' Welch

+

v1.0.5.0..v1.0.5.2 by anders_k

+
+

See Contrib\inetc\inetc.cpp for changes.

diff --git a/build/WINDOWS/Plugins/inetc/Docs/Inetc/wiki.txt b/build/WINDOWS/Plugins/inetc/Docs/Inetc/wiki.txt new file mode 100644 index 0000000000000000000000000000000000000000..384bdd37c9b461d5f075a903de7ec756109432d7 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Docs/Inetc/wiki.txt @@ -0,0 +1,161 @@ +{{PageAuthor|Takhir}} + +== Links == + +Download:
+Inetc.zip
+ +[http://forums.winamp.com/showthread.php?threadid=198596 Forum thread] + +== Description == + +Internet client plug-in for files download and upload. Based on the InetLoad plug-in. Network implementation uses MS WinInet API, supports http/https and ftp protocols. Plugin has better proxy support compared to NSISdl plug-in. Command line may include few URL/File pairs to be transfered. If server or proxy login/password are not set in the script, it displays IE-style authentication dialog (except silent mode). Plug-in supports 3 "transfer in progress" display modes: + +# old NSISdl style - additional embedded progress bar and text on the INSTFILES page; +# POPUP dialog mode with detailed info; +# BANNER mode with simple popup window. + +Plug-in recognizes Installer's Silent mode and this case hides any output (this feature requires NSIS 2.03 or later). Program implements simple re-get functionality - host reconnect and download from current position after short pause. While program depends on IE settings, it changes current IE mode to online. NSISdl code fragment was used for progress bar displaying in the "old style" mode. +For ftp use "host/path" for file location relative to user's home dir and +"host//path" for absolute path. + +== Commands == + +Plug-in DLL functions (entry points): get, post, head, put + +=== get === + +inetc::get [/PROXY IP:PORT] [/USERNAME PROXY_LOGIN /PASSWORD PROXY_PASSWD] + [/NOPROXY] [/NOCANCEL] [/CONNECTTIMEOUT TO_SEC] [/RECEIVETIMEOUT TO_SEC] [/SILENT] [/WEAKSECURITY] + [/CAPTION TEXT] [/NOCOOKIES] [/RESUME RETRY_QUESTION] [/POPUP HOST_ALIAS | /BANNER TEXT] + [/CANCELTEXT CANCEL_TEXT] [/QUESTION CANCEL_QUESTION] [/USERAGENT USER_AGENT_TEXT] + [/HEADER HEADER_TEXT] [/TRANSLATE LANG_PARAMS] [/TOSTACK | /TOSTACKCONV] + URL1 local_file1 [URL2 local_file2 [...]] [/END] +This call returns "OK" string if successful, error description string if failed (see included InetLoad.cpp file for a full set of status strings). Usage and result processing samples are included to the package. + +; /PROXY +: Overwrites current proxy settings, not required in most cases. IE settings will be used by default. + +; /USERNAME +: Proxy username (http only). + +; /PASSWORD +: Proxy password (http only). For server (http/ftp) authentication it is possible to use URL encoded name and password, for example http://username:password@nsis.sourceforge.net. + +; /NOPROXY +: Disables proxy settings for this connection (if any) + +; /NOCANCEL +: Prevents download from being interrupted by user (locks Esc, Alt-F4, Cancel handling) + +; /CONNECTTIMEOUT - +:Sets INTERNET_OPTION_CONNECT_TIMEOUT, seconds, default - IE current parameter value. + +; /RECEIVETIMEOUT - +:Sets INTERNET_OPTION_RECEIVE_TIMEOUT, seconds, default - IE current parameter value. + +; /SILENT +: Key hides plug-in' output (both popup dialog and embedded progress bar). Not required if 'SilentInstall silent' mode was defined in script (NSIS 2.03 or later). + +; /WEAKSECURITY +: Ignore unknown and revoked certificates + +; /RESUME +: On the permanent connection/transfer error instead of exit first displays message box with "resume download" question. Useful for dial-up connections and big files - allows user to restore connection and resume download. Default is "Your internet connection seems to have dropped out!\nPlease reconnect and click Retry to resume downloading...". + +; /CAPTION +: Defines caption text for /BANNER mode, caption prefix (left of '-') for /POPUP mode and caption for RESUME MessageBox. Default is "InetLoad plug-in" if not set or "". + +; /POPUP +: This mode displays detailed download dialog instead of embedded progress bar. Also useful in .onInit function (i.e. not in Section). If HOST_ALIAS is not "", text will replace URL in the dialog - this allows to hide real URL (including password). + +; /BANNER +: Displays simple popup dialog (MSI Banner mode) and sets dialog TEXT (up to 3 lines using $\n). + +; /CANCELTEXT +: Text for the Cancel button in the NSISdl mode. Default is NSIS dialog Cancel button text (current lang). + +; /QUESTION +: Text for the optional MessageBox if user tries to cancel download. If /QUESTION "" was used default "Are you sure that you want to stop download?" will be substituted. + +; /USERAGENT +: UserAgent http request header value. Default is "NSIS_Inetc (Mozilla)". + +; /HEADER +: Adds or replaces http request header. Common HEADER_TEXT format is "header: value". + +; /NOCOOKIES +: Removes cookies from http request + +; /TOSTACK +: Outputs the post/get/head response to the NSIS stack rather than to the file (specify an empty string for local_file1, local_file2 etc.) + +; /TOSTACKCONV +: Outputs the post/get/head response to the NSIS stack while converting character encodings: +: ASCII -> Unicode for the Unicode plug-in build +: Unicode -> ASCII for the ASCII plug-in build + +; /END +: Allows to limit plug-in stack reading (optional, required if you stores other vars in the stack). + +; /TRANSLATE +: Allows translating plug-in text in the POPUP or "old style" (NSISdl) modes (see Readme for parameters). In the BANNER mode text is also customizable. + +=== post === + +inetc::post TEXT2POST [/PROXY IP:PORT] [/USERNAME PROXY_LOGIN /PASSWORD PROXY_PASSWD] + [/NOPROXY] [/NOCANCEL] [/CONNECTTIMEOUT TO_SEC] [/RECEIVETIMEOUT TO_SEC] [/SILENT] [/WEAKSECURITY] + [/FILE] [/CAPTION TEXT] [/NOCOOKIES] [/POPUP HOST_ALIAS | /BANNER TEXT] + [/CANCELTEXT CANCEL_TEXT] [/USERAGENT USER_AGENT_TEXT] [/TRANSLATE LANG_PARAMS] + [/TOSTACK | /TOSTACKCONV] + URL1 local_file1 [URL2 local_file2 [...]] [/END] +Sets POST http mode and defines text string or file name to be used in the POST (http only). Disables auto re-get. No char replaces used (%20 and others). /FILE option allows to send TEXT2POST file content to server, additional 'Filename:' header added to request this case. + +=== head === + +The same as get, but requests http headers only. Writes raw headers to file. + +=== put === + +inetc::put [/PROXY IP:PORT] [/USERNAME PROXY_LOGIN /PASSWORD PROXY_PASSWD] [/NOPROXY] + [/NOCANCEL] [/CONNECTTIMEOUT TO_SEC] [/RECEIVETIMEOUT TO_SEC] [/SILENT] [/WEAKSECURITY] [/CAPTION TEXT] + [/POPUP HOST_ALIAS | /BANNER TEXT] [/CANCELTEXT CANCEL_TEXT] [/USERAGENT USER_AGENT_TEXT] + [/TRANSLATE LANG_PARAMS] [/NOCOOKIES] + URL1 local_file1 [URL2 local_file2 [...]] [/END] +Return value and parameters (if applicable) are the same as for previous entry point. + +== Examples == + + +inetc::put "http://dl.zvuki.ru/6306/mp3/12.mp3" "$EXEDIR\12.mp3" \ + "ftp://dl.zvuki.ru/6306/mp3/11.mp3" "$EXEDIR\11.mp3" +Pop $0 + + + +inetc::put /BANNER "Cameron Diaz upload in progress..." \ +"http://www.dreamgirlswallpaper.co.uk/fiveyearsonline/wallpaper/Cameron_Diaz/camerond09big.JPG" \ +"$EXEDIR\cd.jpg" + Pop $0 + StrCmp $0 "OK" dlok + MessageBox MB_OK|MB_ICONEXCLAMATION "http upload Error, click OK to abort installation" /SD IDOK + Abort +dlok: + ... + + + +; Following attribute also can restore installer Window +; BGGradient 000000 000080 FFFFFF + +== Credits == + +

Many thanks to Backland who offered a simple way to fix NSISdl mode crashes, added 'center parent' function, offers few nice design ideas and spent a lot of time testing the plug-in.

+
+

v1.0.4.4 by Stuart 'Afrow UK' Welch

+

v1.0.5.0..v1.0.5.2 by anders_k

+
+

See Contrib\inetc\inetc.cpp for changes.

+ + +[[Category:Plugins]] diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/Example.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/Example.nsi new file mode 100644 index 0000000000000000000000000000000000000000..4f63c175dee6005204c675594e174b44365046fd --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/Example.nsi @@ -0,0 +1,54 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc plug-in Test" +OutFile "inetc.exe" +;SilentInstall silent +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install-colorful.ico" + !insertmacro MUI_PAGE_WELCOME + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + +;SetFont 14 + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + + SetDetailsView hide + +; two files download, popup mode + inetc::get /caption "2003-2004 reports" /popup "" "http://ineum.narod.ru/spr_2003.htm" "$EXEDIR\spr3.htm" "http://ineum.narod.ru/spr_2004.htm" "$EXEDIR\spr4.htm" /end + Pop $0 # return value = exit code, "OK" means OK + +; single file, NSISdl-style embedded progress bar with specific cancel button text + inetc::get /caption "2005 report" /canceltext "interrupt!" "http://ineum.narod.ru/spr_2005.htm" "$EXEDIR\spr5.htm" /end + Pop $1 # return value = exit code, "OK" means OK + +; banner with 2 text lines and disabled Cancel button + inetc::get /caption "2006 report" /banner "Banner mode with /nocancel option setten$\nSecond Line" /nocancel "http://ineum.narod.ru/spr_2006.htm" "$EXEDIR\spr6.htm" /end + Pop $2 # return value = exit code, "OK" means OK + + MessageBox MB_OK "Download Status: $0, $1, $2" +SectionEnd + + +;-------------------------------- +;Installer Functions + +Function .onInit + +; plug-in auto-recognizes 'no parent dlg' in onInit and works accordingly +; inetc::head /RESUME "Network error. Retry?" "http://ineum.narod.ru/spr_2003.htm" "$EXEDIR\spr3.txt" +; Pop $4 + +FunctionEnd \ No newline at end of file diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/auth_dlg.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/auth_dlg.nsi new file mode 100644 index 0000000000000000000000000000000000000000..6fc8ee17d74d3148b46fa5a25542e50e3a3e102b --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/auth_dlg.nsi @@ -0,0 +1,32 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc http auth Test" +OutFile "auth_dlg.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install-colorful.ico" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + +; Displays IE auth dialog. +; Both server and proxy auth. +; Please test this with your own link. + + inetc::get "http://www.cnt.ru/personal" "$EXEDIR\auth.html" + Pop $0 # return value = exit code, "OK" if OK + MessageBox MB_OK "Download Status: $0" + +SectionEnd diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/ftp_auth.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/ftp_auth.nsi new file mode 100644 index 0000000000000000000000000000000000000000..83c1d967bcc20e4334331d0329058951851ecf42 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/ftp_auth.nsi @@ -0,0 +1,32 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc ftp authentication Test" +OutFile "ftp_auth.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install-colorful.ico" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + +; use your own URL and login@pwd. Password hidden from user with /popup "ALIAS" + + inetc::get /caption "service pack download" /popup "ftp://localhost/" "ftp://login:pwd@localhost/W2Ksp3.exe" "$EXEDIR\sp3.exe" +; inetc::put /caption "service pack upload" /popup "" "ftp://login:pwd@localhost/W2Ksp3.bu.exe" "$EXEDIR\sp3.exe" + Pop $0 # return value = exit code, "OK" if OK + MessageBox MB_OK "Download Status: $0" + +SectionEnd + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/head.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/head.nsi new file mode 100644 index 0000000000000000000000000000000000000000..fef90ec5eb2288c62bacc6fe599845fa9325bf0e --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/head.nsi @@ -0,0 +1,30 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc Head Test" +OutFile "head.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + + DetailPrint "New version check out (internet connection)" + inetc::head /silent "http://ineum.narod.ru/spr_2006.htm" "$EXEDIR\head.txt" + Pop $0 # return value = exit code, "OK" if OK + MessageBox MB_OK "Download Status: $0" + +SectionEnd + + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/headers.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/headers.nsi new file mode 100644 index 0000000000000000000000000000000000000000..b4a2b4841b6d11dab152bd098e047e94d7fe26f8 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/headers.nsi @@ -0,0 +1,32 @@ + +;-------------------------------- +; General Attributes + +Name "Headers Test" +OutFile "headers.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + +; additional headers. Sample php returns raw headers + inetc::get /useragent "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1)" /header "SOAPAction: urn:anonOutInOpe" "http://localhost/headers.php" "$EXEDIR\headers.html" + Pop $0 + + MessageBox MB_OK "Download Status: $0" + +SectionEnd + + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/headers.php b/build/WINDOWS/Plugins/inetc/Examples/Inetc/headers.php new file mode 100644 index 0000000000000000000000000000000000000000..3e395321bb965f73ad87382b92eb3c009d1164f5 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/headers.php @@ -0,0 +1,7 @@ + $value) { + echo "$header: $value
\n"; +} +?> \ No newline at end of file diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/https.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/https.nsi new file mode 100644 index 0000000000000000000000000000000000000000..9bc9975db20548adb0309aa42761a894a3cff277 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/https.nsi @@ -0,0 +1,27 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc https Test" +OutFile "https.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + + inetc::get /POPUP "" /CAPTION "bending_property_demo.zip" "https://secure.codeproject.com/cs/miscctrl/bending_property/bending_property_src.zip" "$EXEDIR\bending_property_src.zip" + Pop $0 # return value = exit code, "OK" if OK + MessageBox MB_OK "Download Status: $0" + +SectionEnd diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/inetc_local.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/inetc_local.nsi new file mode 100644 index 0000000000000000000000000000000000000000..53160aa70dfac01a7a9ff2b9bc70a85ee59f7dfe --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/inetc_local.nsi @@ -0,0 +1,81 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc Local Test" +OutFile "inetc_local.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install-colorful.ico" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + + +; PUT test + +; FTP requires anonymous access in sample below. +; HTTP sample put.php included to package. Stores test.jpg as m2.bmp +; check server files present after upload + + inetc::put "http://localhost/put.php" "$EXEDIR\test.jpg" + Pop $0 + + inetc::put "ftp://localhost/test.jpg" "$EXEDIR\test.jpg" +; not anonymous format +; inetc::put "ftp://login:password@localhost/test.jpg" "$EXEDIR\test.jpg" + Pop $1 + + DetailPrint "PUT: HTTP $0, FTP $1 (verify server files)" + + +; POST test + +; HTTP sample post.php and post_form.htm (to compare results) included + + inetc::post "login=ami&passwd=333" "http://localhost/post.php?lg=iam&pw=44" "$EXEDIR\post_reply.htm" + Pop $2 + + DetailPrint "POST: $2 (post_reply.htm)" + + +; HEAD test + +; uses uploaded earlier test.jpg + + inetc::head /silent "http://localhost/m2.bmp" "$EXEDIR\head.txt" + Pop $3 + + DetailPrint "HEAD: $3 (head.txt)" + + +; GET test + +; 2 files download in nsisdl mode + inetc::get "http://localhost/m2.bmp" "$EXEDIR\get1.jpg" "http://localhost/m2.bmp" "$EXEDIR\get2.jpg" + Pop $4 + + inetc::get /popup "Localhost:GET with Popup" "http://localhost/m2.bmp" "$EXEDIR\get3.jpg" + Pop $5 + + inetc::get /banner "Local Test GET with Banner" "http://localhost/m2.bmp" "$EXEDIR\get4.jpg" + Pop $6 + + inetc::get /silent "ftp://localhost/test.jpg" "$EXEDIR\get5.jpg" + Pop $7 + + DetailPrint "GET: NSISDL $4, POPUP $5, BANNER $6, FTP $7 (get1-5.jpg)" + + SetDetailsView show + +SectionEnd diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/post.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post.nsi new file mode 100644 index 0000000000000000000000000000000000000000..3c64117f8d08907f550ef1af0ef1e6f357a9232e --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post.nsi @@ -0,0 +1,31 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc Post Test" +OutFile "post.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + +; this is my LAN sample, use your own URL for tests. Sample post.php included + + inetc::post "login=ami&passwd=333" "http://localhost/post.php?lg=iam&pw=44" "$EXEDIR\post_reply.htm" + Pop $0 # return value = exit code, "OK" if OK + MessageBox MB_OK "Download Status: $0" + +SectionEnd + + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/post.php b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post.php new file mode 100644 index 0000000000000000000000000000000000000000..d2a40bcb84a9c693973af0141fa7052b1d1048bf --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post.php @@ -0,0 +1,13 @@ + + + + +"; +echo "post.passwd=".$_POST['passwd']."
"; +echo "get.lg=".$_GET['lg']."
"; +echo "get.pw=".$_GET['pw']."
"; +?> + + + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_file.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_file.nsi new file mode 100644 index 0000000000000000000000000000000000000000..c93f2961a8949be6e234b48df9722291144a9eb9 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_file.nsi @@ -0,0 +1,31 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc Post Test" +OutFile "post_file.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + +; this is my LAN sample, use your own URL for tests. Sample post.php included + + inetc::post "$EXEDIR\inetc.cpp" /file "http://localhost/post_file.php" "$EXEDIR\post_file.htm" + Pop $0 # return value = exit code, "OK" if OK + MessageBox MB_OK "Download Status: $0" + +SectionEnd + + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_file.php b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_file.php new file mode 100644 index 0000000000000000000000000000000000000000..1d7b38735cb993d3de5ce5b8fdca1f82dd3f2596 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_file.php @@ -0,0 +1,10 @@ + $value) { + echo "$header: $value
\n"; +} +echo "new
"; +foreach ($_FILES as $key => $value) echo $key . "<>" . $value . "
\n"; +echo file_get_contents('php://input'); +?> \ No newline at end of file diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_form.html b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_form.html new file mode 100644 index 0000000000000000000000000000000000000000..0bde9208d5af4e25068b431b390efedf0970cace --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/post_form.html @@ -0,0 +1,18 @@ + + +Registration form for post.php test + + +This form sends POST request to server. It was interesting to compare server echo
+reply (by included post.php) for this form and InetLoad plug-in - in my
+tests server did not see any difference between them :)
+
+ +
+
+ + +
+ + + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/put.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/put.nsi new file mode 100644 index 0000000000000000000000000000000000000000..ce74a0403628acd30f19595101b2de384416bf0a --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/put.nsi @@ -0,0 +1,31 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc Test" +OutFile "put.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install-colorful.ico" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + +; this is my LAN sample, use your own URL for tests. Login/pwd hidden from user. Sample put.php (for http request) included + + inetc::put "http://localhost/put.php" "$EXEDIR\test.jpg" +; inetc::put /POPUP "ftp://localhost/" /CAPTION "my local ftp upload" "ftp://localhost/test.jpg" "$EXEDIR\test.jpg" + Pop $0 + MessageBox MB_OK "Upload Status: $0" + +SectionEnd diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/put.php b/build/WINDOWS/Plugins/inetc/Examples/Inetc/put.php new file mode 100644 index 0000000000000000000000000000000000000000..dc71943beb0630bad832fa6ef4884f16043cadd6 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/put.php @@ -0,0 +1,19 @@ + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/recursive.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/recursive.nsi new file mode 100644 index 0000000000000000000000000000000000000000..a21aa12aa01e52b2b3893082a551b422b53bfd4e --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/recursive.nsi @@ -0,0 +1,65 @@ +Name "Inetc Recursive Dir Upload Test" +OutFile "recursive.exe" +RequestExecutionLevel user + +!include "MUI2.nsh" +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" +!include "FileFunc.nsh" +!insertmacro GetFileAttributes + +var url +var path + +Function dirul + + Push $0 ; search handle + Push $1 ; file name + Push $2 ; attributes + + FindFirst $0 $1 "$path\*" +loop: + StrCmp $1 "" done + ${GetFileAttributes} "$path\$1" DIRECTORY $2 + IntCmp $2 1 isdir +retry: + Inetc::put $url/$1 "$path\$1" /end + Pop $2 + DetailPrint "$2 $path\$1" + StrCmp $2 "OK" cont + MessageBox MB_YESNO "$path\$1 file upload failed. Retry?" IDYES retry + Abort "terminated by user" + Goto cont +isdir: + StrCmp $1 . cont + StrCmp $1 .. cont + Push $path + Push $url + StrCpy $path "$path\$1" + StrCpy $url "$url/$1" + Call dirul + Pop $url + Pop $path +cont: + FindNext $0 $1 + Goto loop +done: + FindClose $0 + + Pop $2 + Pop $1 + Pop $0 + +FunctionEnd + + +Section "Dummy Section" SecDummy + + SetDetailsView hide + StrCpy $path "$EXEDIR" +; put is dir in the user's ftp home, use //put for root-relative path + StrCpy $url ftp://takhir:pwd@localhost/put + Call dirul + SetDetailsView show + +SectionEnd diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/redirect.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/redirect.nsi new file mode 100644 index 0000000000000000000000000000000000000000..3761692ca2b429816859bc5c5cc7ff1dada8b45d --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/redirect.nsi @@ -0,0 +1,31 @@ + +;-------------------------------- +; General Attributes + +Name "Redirect Test" +OutFile "redirect.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + + SetDetailsView hide + + inetc::get "http://localhost/redirect.php" "$EXEDIR\redirect.htm" /end + Pop $1 + + MessageBox MB_OK "Download Status: $1" + +SectionEnd + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/redirect.php b/build/WINDOWS/Plugins/inetc/Examples/Inetc/redirect.php new file mode 100644 index 0000000000000000000000000000000000000000..900833fe1940dc9ce25eec6458f3d6c6d8e66a90 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/redirect.php @@ -0,0 +1,6 @@ + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/timeout.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/timeout.nsi new file mode 100644 index 0000000000000000000000000000000000000000..a8a306963716b804f7edb15b4ba6dcdd19026bba --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/timeout.nsi @@ -0,0 +1,32 @@ + +;-------------------------------- +; General Attributes + +Name "Timeout Test" +OutFile "to.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + +; additional headers. Sample php returns raw headers + inetc::get /receivetimeout 12 "http://localhost/to.php" "$EXEDIR\to.html" + Pop $0 + + MessageBox MB_OK "Download Status: $0" + +SectionEnd + + diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/tostack.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/tostack.nsi new file mode 100644 index 0000000000000000000000000000000000000000..cfd729b7bd0530c02ab41d4382f6822a29febc82 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/tostack.nsi @@ -0,0 +1,32 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc To Stack Test" +OutFile "ToStack.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install-colorful.ico" + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_LANGUAGE "English" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + + inetc::get /TOSTACK "http://www.google.com" "" /END + Pop $0 # return value = exit code, "OK" if OK + MessageBox MB_OK "Download Status: $0" + Pop $0 # return text + StrLen $1 $0 + MessageBox MB_OK "Download Length: $1" + MessageBox MB_OK "$0" + +SectionEnd diff --git a/build/WINDOWS/Plugins/inetc/Examples/Inetc/translate.nsi b/build/WINDOWS/Plugins/inetc/Examples/Inetc/translate.nsi new file mode 100644 index 0000000000000000000000000000000000000000..079cfea08ff5d5c3b106b6f3775fd8906586caa5 --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/Examples/Inetc/translate.nsi @@ -0,0 +1,33 @@ + +;-------------------------------- +; General Attributes + +Name "Inetc Translate Test" +OutFile "Translate.exe" +RequestExecutionLevel user + + +;-------------------------------- +;Interface Settings + + !include "MUI2.nsh" + !define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install-colorful.ico" + !insertmacro MUI_PAGE_WELCOME + !insertmacro MUI_PAGE_INSTFILES + !insertmacro MUI_PAGE_FINISH + !insertmacro MUI_LANGUAGE "Russian" + + +;-------------------------------- +;Installer Sections + +Section "Dummy Section" SecDummy + +; This is russian variant. See Readme.txt for a list of parameters. +; Use LangStrings as TRANSLATE parameters for multilang options + + inetc::load /POPUP "" /CAPTION " " /TRANSLATE "URL" "" " " " " "" "" "" "http://ineum.narod.ru/g06s.htm" "$EXEDIR\g06s.htm" + Pop $0 # return value = exit code, "OK" if OK + MessageBox MB_OK "Download Status: $0" + +SectionEnd diff --git a/build/WINDOWS/Plugins/inetc/Plugins/amd64-unicode/INetC.dll b/build/WINDOWS/Plugins/inetc/Plugins/amd64-unicode/INetC.dll new file mode 100644 index 0000000000000000000000000000000000000000..2427cfd44282965614020cdc0622ac591ceb0431 Binary files /dev/null and b/build/WINDOWS/Plugins/inetc/Plugins/amd64-unicode/INetC.dll differ diff --git a/build/WINDOWS/Plugins/inetc/Plugins/x86-ansi/INetC.dll b/build/WINDOWS/Plugins/inetc/Plugins/x86-ansi/INetC.dll new file mode 100644 index 0000000000000000000000000000000000000000..d01dc48a6b158b5a2e37823b49e69ac7edbcf97c Binary files /dev/null and b/build/WINDOWS/Plugins/inetc/Plugins/x86-ansi/INetC.dll differ diff --git a/build/WINDOWS/Plugins/inetc/Plugins/x86-unicode/INetC.dll b/build/WINDOWS/Plugins/inetc/Plugins/x86-unicode/INetC.dll new file mode 100644 index 0000000000000000000000000000000000000000..d867f8c2e8ebf247c282e8f76c6cfbb3032d95e3 Binary files /dev/null and b/build/WINDOWS/Plugins/inetc/Plugins/x86-unicode/INetC.dll differ diff --git a/build/WINDOWS/Plugins/inetc/build_msvc.cmd b/build/WINDOWS/Plugins/inetc/build_msvc.cmd new file mode 100644 index 0000000000000000000000000000000000000000..0b7d8fbc8600841313e33d7e1ced52064b2af7cf --- /dev/null +++ b/build/WINDOWS/Plugins/inetc/build_msvc.cmd @@ -0,0 +1,26 @@ +@echo off +setlocal +set Name=INetC +set DistRoot=. +set SrcRoot=%DistRoot%\Contrib\%Name% +set BaseCL=/GL /LD /W3 /O1 /Osy /GF /Gz /GS- /GR- /Zl /D_VC_NODEFAULTLIB +set BaseLINK=/LTCG /DLL /OPT:REF /OPT:ICF,99 /MERGE:.rdata=.text /OPT:NOWIN98 /NODEFAULTLIB kernel32.lib user32.lib advapi32.lib comctl32.lib wininet.lib +set Targets=x86-ansi x86-unicode +(>nul (( 2>&1 call cl "/?" )|find /I "AMD64"))&&(set Targets=amd64-unicode) +for %%A in (%Targets%) do (call :B %%A) +@goto :EOF + + +:B targ +set DEF=/D___NSISPLUGIN +((echo %1|find /I "unicode")>nul)&&set DEF=%DEF% /DUNICODE /D_UNICODE +set CL=%BaseCL% %DEF% /Gy +set LINK=%BaseLINK% +for %%B in (%SrcRoot%\*.rc) do call RC /R /FO"%DistRoot%\%%~nB.res" "%%B" +for %%A in (c cpp cxx) do for %%B in (%SrcRoot%\*.%%A) do ( + if exist "%DistRoot%\%%~nB.obj" del "%DistRoot%\%%~nB.obj" + call CL /c %%B /Fe"%DistRoot%\%Name%" + ) +md "%DistRoot%\Plugins\%1" 2>nul +call LINK /NOLOGO /OUT:"%DistRoot%\Plugins\%1\%Name%.dll" /PDB:"%DistRoot%\%Name%-%1" "%DistRoot%\*.obj" "%DistRoot%\*.res" +@goto :EOF diff --git a/build/WINDOWS/TvhLib.nsh b/build/WINDOWS/TvhLib.nsh new file mode 100644 index 0000000000000000000000000000000000000000..667c19e151b2da5e5e6ec0b1a325e6cc4a02c9db --- /dev/null +++ b/build/WINDOWS/TvhLib.nsh @@ -0,0 +1,317 @@ +Var Dialog +Var lblLabel +Var lblUsername +Var lblPassword +Var txtUsername +Var pwdPassword +Var pwdConfirmPassword +Var hwnd +Var user +Var pwd +Var pwd2 +Var subfolder +Var cmd +Var pythoninstall +Var pythonpath +Var DataFolder +Var txtDataFolder +Var BROWSEDATA + +!include "CharToASCII.nsh" +!include "Base64.nsh" + + +Function DataFolderPage + nsDialogs::Create /NOUNLOAD 1018 + Pop $Dialog + ${If} $Dialog == error + Abort + ${EndIf} + CreateDirectory "$DataFolder" + ${NSD_CreateLabel} 0 0 100% 24u "Please specify the Cabernet data folder. \ + Writeable by the user: System$\r$\nIt is highly recommended to have \ + this folder be easy to access." + ${NSD_CreateGroupBox} 0 40u 100% 34u "Data Folder" + ${NSD_CreateText} 3% 54u 77% 12u "$DataFolder" + Pop $txtDataFolder + ${NSD_CreateBrowseButton} 82% 54u 15% 13u "Browse" + pop $BROWSEDATA + ${NSD_OnClick} $BROWSEDATA BrowseData + nsDialogs::Show +FunctionEnd + +Function BrowseData + nsDialogs::SelectFolderDialog "Select Data Folder" "$DataFolder" + pop $0 + ${If} $0 != error + ${NSD_SetText} $txtDataFolder $0 + StrCpy $DataFolder $0 + ${EndIf} +FunctionEnd + +Function DataFolderPageLeave + ${NSD_GetText} $txtDataFolder $DataFolder +FunctionEnd + +Function UserPassPage + nsDialogs::Create /NOUNLOAD 1018 + Pop $Dialog + ${If} $Dialog == error + Abort + ${EndIf} + ${NSD_CreateLabel} 0 0 100% 24u "Please specify LocastUsername and Password." + Pop $lblLabel + ${NSD_CreateLabel} 0 30u 60u 12u "Username:" + Pop $lblUsername + ${NSD_CreateText} 65u 30u 50% 12u "" + Pop $txtUsername + ${NSD_CreateLabel} 0 45u 60u 12u "Password:" + Pop $lblPassword + ${NSD_CreatePassword} 65u 45u 50% 12u "" + Pop $pwdPassword + ${NSD_CreateLabel} 0 60u 60u 12u "Confirm Password:" + Pop $lblPassword + ${NSD_CreatePassword} 65u 60u 50% 12u "" + Pop $pwdConfirmPassword + ${NSD_CreateCheckbox} 65u 75u 50% 12u "Show password" + Pop $hwnd + ${NSD_OnClick} $hwnd ShowPassword + nsDialogs::Show +FunctionEnd + +Function UserPassPageLeave + ${NSD_GetText} $txtUsername $user + ${NSD_GetText} $pwdPassword $pwd + ${NSD_GetText} $pwdConfirmPassword $pwd2 + ${If} $user == "" + ${OrIf} $pwd == "" + ${OrIf} $pwd2 == "" + MessageBox MB_OK "All entries are required" + Abort + ${EndIf} + ${If} $pwd != $pwd2 + MessageBox MB_OK "passwords do not match, try again" + Abort + ${EndIf} + ${Base64_Encode} $pwd + Pop $0 + StrCpy $pwd $0 +FunctionEnd + +Function ShowPassword + Pop $hwnd + ${NSD_GetState} $hwnd $0 + ShowWindow $pwdPassword ${SW_HIDE} + ShowWindow $pwdConfirmPassword ${SW_HIDE} + ${If} $0 == 1 + SendMessage $pwdPassword ${EM_SETPASSWORDCHAR} 0 0 + SendMessage $pwdConfirmPassword ${EM_SETPASSWORDCHAR} 0 0 + ${Else} + SendMessage $pwdPassword ${EM_SETPASSWORDCHAR} 42 0 + SendMessage $pwdConfirmPassword ${EM_SETPASSWORDCHAR} 42 0 + ${EndIf} + ShowWindow $pwdPassword ${SW_SHOW} + ShowWindow $pwdConfirmPassword ${SW_SHOW} +FunctionEnd + +Function TestPython + !define SOURCEPATH "../.." + SetOutPath "$INSTDIR" + File "${SOURCEPATH}\build\WINDOWS\findpython.pyw" + StrCpy $cmd 'python findpython.pyw' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 PythonFound + MessageBox MB_OK "Python 3.x not found, Make sure to install python$\r$\n\ + for all users if a Windows Service is needed or single user$\r$\n\ + without admin access" + StrCpy $pythonpath "" + Goto PythonMissing + PythonFound: + MessageBox MB_OK "Using Python installation $1$\r$\n\ + If this is not correct, please uninstall the unwanted python versions" + Push $1 + Call Trim + Pop $pythonpath + Call ClearPythonInstallFlag + PythonMissing: + Delete $INSTDIR\findpython.pyw +FunctionEnd + +Function TestPythonSilent + SetOutPath "$INSTDIR" + File "${SOURCEPATH}\build\WINDOWS\findpython.pyw" + nsExec::ExecToStack 'python findpython.pyw' + Pop $0 ;return value + Pop $1 ;return value + IntCmp $0 0 PythonFound + StrCpy $pythonpath "" + Goto PythonMissing + PythonFound: + Push $1 + Call Trim + Pop $pythonpath + ;StrCpy $pythonpath $1 + PythonMissing: + Delete $INSTDIR\findpython.pyw +FunctionEnd + +Function UpdateConfig + SetOutPath "$INSTDIR" + StrCpy $cmd 'python -m build.WINDOWS.UpdateConfig -i "$INSTDIR" -d "$DataFolder"' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 PythonDone + MessageBox MB_OK "Unable to update Config file. Edit the file manually. $0 $1" + PythonDone: +FunctionEnd + +Function AddFiles + ; !define SOURCEPATH "../.." + SetOutPath "$INSTDIR" + File "${SOURCEPATH}\tvh_main.py" + File "${SOURCEPATH}\LICENSE" + File "${SOURCEPATH}\CHANGELOG.md" + File "${SOURCEPATH}\.dockerignore" + File "${SOURCEPATH}\docker-compose.yml" + File "${SOURCEPATH}\Dockerfile" + File "${SOURCEPATH}\Docker_entrypoint.sh" + File "${SOURCEPATH}\Dockerfile_tvh_crypt.alpine" + File "${SOURCEPATH}\Dockerfile_tvh_crypt.slim-buster" + File "${SOURCEPATH}\README.md" + File "${SOURCEPATH}\TVHEADEND.md" + File "${SOURCEPATH}\requirements.txt" + Rename "$INSTDIR\TVHEADEND.md" "$INSTDIR\README.txt" + + SetOutPath "$INSTDIR\lib" + File /r /x __pycache__ /x development "${SOURCEPATH}\lib\*.*" + SetOutPath "$INSTDIR\plugins" + File /r /x __pycache__ "${SOURCEPATH}\plugins\*.*" + + SetOutPath "$INSTDIR\build\WINDOWS" + File "${SOURCEPATH}\build\WINDOWS\UpdateConfig.pyw" +FunctionEnd + +; arg: $subfolder +; return: $subfolder +Function GetSubfolder + FindFirst $0 $1 "$subfolder" + StrCmp $1 "" empty + ${If} ${FileExists} "$subfolder" + StrCpy $subfolder $1 + ${EndIf} + Goto done + empty: + StrCpy $subfolder "" + done: + FindClose $0 +FunctionEnd + + +Function InstallService + Call TestPythonSilent + StrCmp "$pythonpath" "" 0 found + MessageBox MB_OK "Unable to detect python install, aborting $pythonpath" + Abort + found: + StrCpy $cmd '"$INSTDIR\lib\tvheadend\service\Windows\nssm.exe" install Cabernet \ + "$pythonpath" "\""$INSTDIR\tvh_main.py\""" -c "\""$DataFolder\config.ini\"""' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 5 ServiceAlreadyInstalled + IntCmp $0 0 ServiceDone + MessageBox MB_OK "Service not installed. status:$0 $1" + ServiceDone: + StrCpy $cmd '$INSTDIR\lib\tvheadend\service\Windows\nssm.exe set Cabernet AppDirectory "$INSTDIR"' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 Service2Done + MessageBox MB_OK "Service update AppDirectory failed. status:$0 $1" + Service2Done: + CreateDirectory "$TEMP\cabernet" + StrCpy $cmd '$INSTDIR\lib\tvheadend\service\Windows\nssm.exe set Cabernet AppStdout "$TEMP\cabernet\out.log"' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 Service3Done + MessageBox MB_OK "Service update AppDirectory failed. status:$0 $1" + Service3Done: + StrCpy $cmd '$INSTDIR\lib\tvheadend\service\Windows\nssm.exe set Cabernet AppStderr "$TEMP\cabernet\error.log"' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 Service4Done + MessageBox MB_OK "Service update AppDirectory failed. status:$0 $1" + Service4Done: + StrCpy $cmd '$INSTDIR\lib\tvheadend\service\Windows\nssm.exe set Cabernet AppStdoutCreationDisposition 2' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 Service5Done + MessageBox MB_OK "Service update AppDirectory failed. status:$0 $1" + Service5Done: + StrCpy $cmd '$INSTDIR\lib\tvheadend\service\Windows\nssm.exe set Cabernet AppStderrCreationDisposition 2' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 Service6Done + MessageBox MB_OK "Service update AppDirectory failed. status:$0 $1" + Goto Service6Done + ServiceAlreadyInstalled: + MessageBox MB_OK "Service already installed" + Service6Done: +FunctionEnd + + +Function un.installService + StrCpy $cmd '"$INSTDIR\lib\tvheadend\service\Windows\nssm.exe" stop Cabernet' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + StrCpy $cmd '"$INSTDIR\lib\tvheadend\service\Windows\nssm.exe" remove Cabernet confirm' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 ServiceDone + MessageBox MB_OK "Service not uninstalled. status:$0 $1" + ServiceDone: +FunctionEnd + + +; Trim +; Removes leading & trailing whitespace from a string +; Usage: +; Push +; Call Trim +; Pop +Function Trim + Exch $R1 ; Original string + Push $R2 +Loop: + StrCpy $R2 "$R1" 1 + StrCmp "$R2" " " TrimLeft + StrCmp "$R2" "$\r" TrimLeft + StrCmp "$R2" "$\n" TrimLeft + StrCmp "$R2" "$\t" TrimLeft + GoTo Loop2 +TrimLeft: + StrCpy $R1 "$R1" "" 1 + Goto Loop +Loop2: + StrCpy $R2 "$R1" 1 -1 + StrCmp "$R2" " " TrimRight + StrCmp "$R2" "$\r" TrimRight + StrCmp "$R2" "$\n" TrimRight + StrCmp "$R2" "$\t" TrimRight + GoTo Done +TrimRight: + StrCpy $R1 "$R1" -1 + Goto Loop2 +Done: + Pop $R2 + Exch $R1 +FunctionEnd diff --git a/build/WINDOWS/UpdateConfig.pyw b/build/WINDOWS/UpdateConfig.pyw new file mode 100644 index 0000000000000000000000000000000000000000..c32afe48f0cf943f1b40a793a31bb3d0aa63cf82 --- /dev/null +++ b/build/WINDOWS/UpdateConfig.pyw @@ -0,0 +1,88 @@ +#!/usr/bin/env python +''' +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +''' + +import os +import sys +import time +import argparse +import platform +import pathlib +import base64 +import binascii +import logging + +from lib.config.user_config import get_config + + +def get_args(): + parser = argparse.ArgumentParser(description='Fetch provider', epilog='') + parser.add_argument('-i', '--installdir', dest='instdir', type=str, default=None, help='', required=True) + parser.add_argument('-c', '--configfile', dest='cfg', type=str, default=None, help='') + parser.add_argument('-d', '--datadir', dest='datadir', type=str, default=None, help='') + return parser.parse_args() + + +# Startup Logic +if __name__ == '__main__': + # os.chdir(os.path.dirname(os.path.abspath(__file__))) + script_dir = pathlib.Path(os.path.dirname(os.path.abspath(__file__))) + opersystem = platform.system() + + args = get_args() + + # determine if a config.ini file exists at the top folder of the install + # if not found, use the example config file + # otherwise, update currect config.ini with the new user/pwd info + install_dir = pathlib.Path(os.path.abspath(str(args.instdir))) + if not os.path.exists(install_dir): + print('ERROR: install directory not found at ', install_dir) + sys.exit(1) + + data_dir = pathlib.Path(os.path.abspath(str(args.datadir))) + if not os.path.exists(data_dir): + os.makedirs(data_dir) + print('INFO: Creating data directory: ', data_dir) + + config_file = pathlib.Path(data_dir).joinpath('config.ini') + if os.path.exists(config_file): + print('config file found at ', config_file) + args.cfg = config_file + # update current config.ini file + else: + print('Creating new config file at ', config_file) + # find the examples config file + config_ex_file = pathlib.Path(install_dir).joinpath('lib/tvheadend/config_example.ini') + if os.path.exists(config_ex_file): + print('config example file found at ', config_ex_file) + args.cfg = config_ex_file + else: + print('ERROR: config example file not found at ', config_ex_file) + sys.exit(1) + + + configObj = get_config(install_dir, opersystem, args) + + # update config object + if not configObj.config_handler.has_section('paths'): + configObj.config_handler.add_section('paths') + configObj.data['paths']['data_dir'] = str(data_dir) + configObj.config_handler.set('paths', 'data_dir', str(data_dir)) + + with open(config_file, 'w') as config_fileptr: + configObj.config_handler.write(config_fileptr) diff --git a/build/WINDOWS/__init__.py b/build/WINDOWS/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/build/WINDOWS/buildwin.nsi b/build/WINDOWS/buildwin.nsi new file mode 100644 index 0000000000000000000000000000000000000000..ab22280f6e836250ba3403d25571ef5b753649f2 --- /dev/null +++ b/build/WINDOWS/buildwin.nsi @@ -0,0 +1,289 @@ +; Script generated by the HM NIS Edit Script Wizard. + +!searchparse /noerrors /file ..\..\lib\common\utils.py 'VERSION = ' VERSION + +; HM NIS Edit Wizard helper defines +!define PRODUCT_NAME "cabernet" +!define PRODUCT_VERSION ${VERSION} +!define PRODUCT_PUBLISHER "rocky4546" +!define PRODUCT_WEB_SITE "https://github.com/cabernetwork/cabernet" +!define PRODUCT_DIR_REGKEY "Software\Microsoft\Windows\CurrentVersion\App Paths\tvh_main.py" +!define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" +!define PRODUCT_UNINST_ROOT_KEY "HKLM" +!define PRODUCT_STARTMENU_REGVAL "NSIS:StartMenuDir" + +; MultiUser variables for single user and admin installs +!define MULTIUSER_EXECUTIONLEVEL Highest +!define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCT_NAME}" +!define MULTIUSER_MUI +!define MULTIUSER_INSTALLMODE_COMMANDLINE +!define MULTIUSER_INSTALLMODE_FUNCTION onMultiUserModeChanged + +; MUI 1.67 compatible ------ +!addplugindir '.\Plugins\inetc\Plugins\x86-unicode' + +!include "MUI.nsh" +!include "MultiUser.nsh" +!include "MUI2.nsh" +!include nsDialogs.nsh +!include "TvhLib.nsh" + +; MUI Settings +!define MUI_ABORTWARNING +!define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico" +!define MUI_UNICON "${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico" + +; Welcome page +!insertmacro MUI_PAGE_WELCOME +;!define MUI_PAGE_CUSTOMFUNCTION_PRE LicenseFiles + +!insertmacro MULTIUSER_PAGE_INSTALLMODE + + +; License page +!insertmacro MUI_PAGE_LICENSE "../../LICENSE" +; Directory page +!insertmacro MUI_PAGE_DIRECTORY +; get data folder +Page custom DataFolderPage DataFolderPageLeave +; Components page +!define MUI_PAGE_CUSTOMFUNCTION_PRE TestPython +!insertmacro MUI_PAGE_COMPONENTS +; Start menu page +var ICONS_GROUP +!define MUI_STARTMENUPAGE_NODISABLE +!define MUI_STARTMENUPAGE_DEFAULTFOLDER "${PRODUCT_NAME}" +!define MUI_STARTMENUPAGE_REGISTRY_ROOT "${PRODUCT_UNINST_ROOT_KEY}" +!define MUI_STARTMENUPAGE_REGISTRY_KEY "${PRODUCT_UNINST_KEY}" +!define MUI_STARTMENUPAGE_REGISTRY_VALUENAME "${PRODUCT_STARTMENU_REGVAL}" +!insertmacro MUI_PAGE_STARTMENU Application $ICONS_GROUP +; Instfiles page +!insertmacro MUI_PAGE_INSTFILES +; Finish page +!define MUI_FINISHPAGE_SHOWREADME "$INSTDIR\README.txt" +!insertmacro MUI_PAGE_FINISH + +; Uninstaller pages +!insertmacro MUI_UNPAGE_INSTFILES + +; Language files +!insertmacro MUI_LANGUAGE "English" + + +; MUI end ------ + +Name "${PRODUCT_NAME}-${PRODUCT_VERSION}" +OutFile "${PRODUCT_NAME}-${PRODUCT_VERSION}.exe" +InstallDir "$PROGRAMFILES64\${PRODUCT_NAME}" +InstallDirRegKey HKLM "${PRODUCT_DIR_REGKEY}" "" +ShowInstDetails show +ShowUnInstDetails show + +Section "Install Python3" SEC01 + SetOutPath "$INSTDIR" + SetOverwrite ifnewer + inetc::get /BANNER "Python3 download in progress..." \ + "https://www.python.org/ftp/python/3.9.5/python-3.9.5-amd64.exe" \ + "$TEMP\python.exe" /END + Pop $0 + StrCmp $0 "OK" dlok + MessageBox MB_OK|MB_ICONEXCLAMATION "ERROR::HTTP Download Error while trying to download Python, $\r$\n\ + $0$\r$\n\ + Click OK to abort installation" /SD IDOK + Abort + dlok: + + nsExec::ExecToStack $pythoninstall + Pop $0 ;return value + Pop $1 ; status text + IntCmp $0 0 PythonDone + MessageBox MB_OK "Python not installed status:$0, Aborting" + Abort + PythonDone: + + DELETE "$TEMP\python.exe" + + Call TestPythonSilent + ${If} $pythonpath == "" + MessageBox MB_OK "Please restart the installer to pick up the python installation, Aborting" + Abort + ${EndIf} + +SectionEnd + +Function ClearPythonInstallFlag + SectionSetFlags ${SEC01} 0 # python install section +FunctionEnd + +Section "MainSection" SEC02 + SetOutPath "$INSTDIR" + SetOverwrite ifnewer + Call AddFiles + Call UpdateConfig + + ; Shortcuts + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + CreateDirectory "$SMPROGRAMS\$ICONS_GROUP" + SetOutPath "$INSTDIR" + CreateShortCut "$SMPROGRAMS\$ICONS_GROUP\cabernet.lnk" "$INSTDIR\tvh_main.py" + SetOutPath "$INSTDIR" + ; CreateShortCut "$DESKTOP\cabernet.lnk" "$INSTDIR\tvh_main.py" + !insertmacro MUI_STARTMENU_WRITE_END +SectionEnd + +Section "Windows Service" SEC03 + SetOutPath "$INSTDIR" + SetOverwrite ifnewer + Call InstallService +SectionEnd + +Section "Install FFMPEG" SEC04 + SetOutPath "$INSTDIR" + SetOverwrite ifnewer + inetc::get /BANNER "FFMPEG download in progress..." \ + "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" \ + "$TEMP\ffmpeg.zip" /END + Pop $0 + StrCmp $0 "OK" dlok + MessageBox MB_OK|MB_ICONEXCLAMATION "ERROR::HTTP Download Error while trying to download FFMPEG, $\r$\n\ + $0$\r$\n\ + Click OK to abort installation" /SD IDOK + Abort + dlok: + StrCpy $cmd 'powershell expand-archive \"$TEMP\ffmpeg.zip\" \"$TEMP\ffmpeg\"' + nsExec::ExecToStack '$cmd' + Pop $0 ;return value + Pop $1 ; status text + ;MessageBox MB_OK "FFMPEG Extract Status:$0 $1" + + StrCpy $subfolder "$TEMP\ffmpeg\ffmpeg*.*" + Call GetSubfolder + StrCmp $subfolder "" empty + CopyFiles "$TEMP\ffmpeg\$subfolder\bin\*.exe" "$INSTDIR\ffmpeg\bin" + CopyFiles "$TEMP\ffmpeg\$subfolder\LICENSE" "$INSTDIR\ffmpeg" + CopyFiles "$TEMP\ffmpeg\$subfolder\README.txt" "$INSTDIR\ffmpeg" + ; if subfolder is not found, then do nothing + empty: + DELETE "$TEMP\ffmpeg.zip" + RMDIR /r "$TEMP\ffmpeg\*.*" + RMDIR "$TEMP\ffmpeg" + SetOutPath "$INSTDIR" +SectionEnd + + + +Section -AdditionalIcons + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + WriteIniStr "$INSTDIR\${PRODUCT_NAME}.url" "InternetShortcut" "URL" "${PRODUCT_WEB_SITE}" + CreateShortCut "$SMPROGRAMS\$ICONS_GROUP\Website.lnk" "$INSTDIR\${PRODUCT_NAME}.url" + CreateShortCut "$SMPROGRAMS\$ICONS_GROUP\Uninstall.lnk" "$INSTDIR\uninst.exe" + !insertmacro MUI_STARTMENU_WRITE_END +SectionEnd + + +Section -Post + WriteUninstaller "$INSTDIR\uninst.exe" + WriteRegStr HKLM "${PRODUCT_DIR_REGKEY}" "" "$INSTDIR\main.py" + WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "DisplayName" "$(^Name)" + WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "UninstallString" "$INSTDIR\uninst.exe" + WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "DisplayIcon" "$INSTDIR\main.py" + WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "DisplayVersion" "${PRODUCT_VERSION}" + WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "URLInfoAbout" "${PRODUCT_WEB_SITE}" + WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" +SectionEnd + + +; Section descriptions +!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${SEC01} "Installs python for all users. Required for the Windows Service" + !insertmacro MUI_DESCRIPTION_TEXT ${SEC02} "Installs the base app" + !insertmacro MUI_DESCRIPTION_TEXT ${SEC03} "Adds a Windows Service using nssm." + !insertmacro MUI_DESCRIPTION_TEXT ${SEC04} "Installs ffmpeg in app folder. Currently required for most providers" +!insertmacro MUI_FUNCTION_DESCRIPTION_END + +Function onMultiUserModeChanged + ${If} $MultiUser.InstallMode == "CurrentUser" + StrCpy $INSTDIR "$LocalAppdata\Programs\${MULTIUSER_INSTALLMODE_INSTDIR}" + StrCpy $pythoninstall '$TEMP\python.exe InstallAllUsers=0 PrependPath=1' + SectionSetFlags ${SEC03} 16 + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCT_NAME}" + StrCpy $pythoninstall '$TEMP\python.exe InstallAllUsers=1 PrependPath=1' + SectionSetFlags ${SEC03} 1 + ${EndIf} +FunctionEnd + + +Function .onInit + StrCpy $DataFolder "C:\Windows\system32\config\systemprofile\Documents\cabernet" + !insertmacro MULTIUSER_INIT + SectionSetFlags ${SEC02} 17 # main section + ;SectionSetFlags ${SEC04} 0 # ffmpeg section, able to select, but not selected + SectionSetFlags ${SEC04} 1 # main section +FunctionEnd + + +Function un.onInit + Var /GLOBAL remove_all + !insertmacro MULTIUSER_UNINIT + MessageBox MB_ICONQUESTION|MB_YESNO|MB_DEFBUTTON2 "Are you sure you want to completely remove $(^Name)?" IDYES +2 + Abort + MessageBox MB_ICONQUESTION|MB_YESNO|MB_DEFBUTTON2 "Do you want to remove all data and plugins?" IDYES true2 + StrCpy $remove_all "0" + Goto end2 + true2: + StrCpy $remove_all "1" + end2: +FunctionEnd + + +Function un.onUninstSuccess + HideWindow + MessageBox MB_ICONINFORMATION|MB_OK "$(^Name) was successfully removed from your computer." +FunctionEnd + + +Section Uninstall + !insertmacro MUI_STARTMENU_GETFOLDER "Application" $ICONS_GROUP + ${If} $MultiUser.InstallMode != "CurrentUser" + Call un.installService + ${EndIf} + + ${If} $remove_all == "1" + RMDIR /r "$INSTDIR\*.*" + ${Else} + #Delete Cabernet folders + RMDIR /r "$INSTDIR\build" + RMDIR /r "$INSTDIR\lib" + RMDIR /r "$INSTDIR\plugins" + + #Delete Cabernet files + Delete "$INSTDIR\CHANGE*.*" + Delete "$INSTDIR\Dock*" + Delete "$INSTDIR\LIC*" + Delete "$INSTDIR\READ*.*" + Delete "$INSTDIR\req*.*" + Delete "$INSTDIR\tvh*.*" + Delete "$INSTDIR\uninst.exe" + Delete "$INSTDIR\${PRODUCT_NAME}.url" + ${EndIf} + + Delete "$SMPROGRAMS\$ICONS_GROUP\Uninstall.lnk" + Delete "$SMPROGRAMS\$ICONS_GROUP\Website.lnk" + Delete "$DESKTOP\cabernet.lnk" + Delete "$SMPROGRAMS\$ICONS_GROUP\cabernet.lnk" + + RMDir "$SMPROGRAMS\$ICONS_GROUP" + + ${If} $remove_all == "1" + RMDir "$INSTDIR" + ${EndIf} + + DeleteRegKey ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" + DeleteRegKey HKLM "${PRODUCT_DIR_REGKEY}" + SetAutoClose true +SectionEnd + + +; git ignore +; verify git status \ No newline at end of file diff --git a/build/WINDOWS/cache/README.txt b/build/WINDOWS/cache/README.txt new file mode 100644 index 0000000000000000000000000000000000000000..52c93aecba951dc1c54791b287ce77dbae46a0ff --- /dev/null +++ b/build/WINDOWS/cache/README.txt @@ -0,0 +1 @@ +This is needed to run UpdateConfig.pyw \ No newline at end of file diff --git a/build/WINDOWS/findpython.pyw b/build/WINDOWS/findpython.pyw new file mode 100644 index 0000000000000000000000000000000000000000..656ca51f78794883db26ef8cd4d894fa84175cf4 --- /dev/null +++ b/build/WINDOWS/findpython.pyw @@ -0,0 +1,7 @@ +import os +import sys +x = sys.executable +if x is None or x == '': + sys.exit(1) +else: + print(x) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..925ad93c01db04765eb22be027d6076137b39b44 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +# Things to review/change: +# PUID/PGID change to the user running Cabernet +# Volumes: All volume paths are to be updated. The secrets folder +# contains the private key for encryption and should be protected. +# example volumes: +# - ../docker/cabernet/data:/app/data # App data (Optional) +# - ../docker/cabernet/plugins_ext:/app/plugins_ext # Plugins Data (Optional) +# - ../docker/cabernet/secrets:/app/.cabernet # Ecryption key data (Optional) +# This will add a docker folder at the same level as the cabernet source +# with the external folders for docker + +version: '2.4' +services: + cabernet: + container_name: cabernet + image: ghcr.io/cabernetwork/cabernet:latest + environment: + - TZ="Etc/UTC" # Timezone (Optional) + - PUID=1000 # UserID (Optional) + - PGID=1000 # GroupID (Optional) + ports: + - "6077:6077" # Web Interface Port + - "5004:5004" # Port used to stream + restart: unless-stopped + volumes: + - /path/to/cabernet/data:/app/data # App data (Optional) + - /path/to/cabernet/plugins_ext:/app/plugins_ext # Plugins Data (Optional) + - /path/to/cabernet/secrets:/app/.cabernet # Ecryption key data (Optional) diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/clients/__init__.py b/lib/clients/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..67d5f683b9a40fff24d63eb6a0f00c6f9991521b --- /dev/null +++ b/lib/clients/__init__.py @@ -0,0 +1,2 @@ +import lib.clients.epg2xml +import lib.clients.channels diff --git a/lib/clients/channels/__init__.py b/lib/clients/channels/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..316f33673207a0ae4a0b7b2dd77f61583358eea6 --- /dev/null +++ b/lib/clients/channels/__init__.py @@ -0,0 +1,4 @@ +import lib.clients.channels.channels +import lib.clients.channels.channels_html +import lib.clients.channels.channels_form_html + diff --git a/lib/clients/channels/channels.py b/lib/clients/channels/channels.py new file mode 100644 index 0000000000000000000000000000000000000000..ecf26df6403dd569edca41e2f39b3245f9b34cc0 --- /dev/null +++ b/lib/clients/channels/channels.py @@ -0,0 +1,327 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import io +import logging +import urllib.request +from io import StringIO +from xml.sax.saxutils import escape + +import lib.common.utils as utils +from lib.clients.channels.templates import ch_templates +from lib.common.decorators import getrequest +from lib.db.db_channels import DBChannels +import lib.image_size.get_image_size as get_image_size +from lib.common.decorators import handle_url_except + + +@getrequest.route('/playlist') +def playlist(_webserver): + _webserver.send_response(302) + _webserver.send_header('Location', _webserver.path.replace('playlist', 'channels.m3u')) + _webserver.end_headers() + + +@getrequest.route('/channels.m3u') +def channels_m3u(_webserver): + _webserver.do_mime_response(200, 'audio/x-mpegurl', get_channels_m3u( + _webserver.config, _webserver.stream_url, + _webserver.query_data['name'], + _webserver.query_data['instance'], + _webserver.plugins.plugins + )) + + +@getrequest.route('/lineup.xml') +def lineup_xml(_webserver): + _webserver.do_mime_response(200, 'application/xml', get_channels_xml( + _webserver.config, _webserver.stream_url, + _webserver.query_data['name'], + _webserver.query_data['instance'], + _webserver.plugins.plugins + )) + + +@getrequest.route('/lineup.json') +def lineup_json(_webserver): + _webserver.do_mime_response(200, 'application/json', get_channels_json( + _webserver.config, _webserver.stream_url, + _webserver.query_data['name'], + _webserver.query_data['instance'], + _webserver.plugins.plugins + )) + + +def get_channels_m3u(_config, _base_url, _namespace, _instance, _plugins): + format_descriptor = '#EXTM3U' + record_marker = '#EXTINF' + ch_obj = ChannelsURL(_config, _base_url) + + db = DBChannels(_config) + ch_data = db.get_channels(_namespace, _instance) + fakefile = StringIO() + fakefile.write( + '%s\n' % format_descriptor + ) + + sids_processed = [] + for sid, sid_data_list in ch_data.items(): + for sid_data in sid_data_list: + if sid in sids_processed: + continue + if not sid_data['enabled'] \ + or not _plugins.get(sid_data['namespace']) \ + or not _plugins[sid_data['namespace']].enabled: + continue + if not _plugins[sid_data['namespace']] \ + .plugin_obj.instances[sid_data['instance']].enabled: + continue + config_section = utils.instance_config_section(sid_data['namespace'], sid_data['instance']) + if not _config[config_section]['enabled']: + continue + sids_processed.append(sid) + stream = _config[config_section]['player-stream_type'] + if stream == 'm3u8redirect' and sid_data['json'].get('stream_url'): + uri = sid_data['json']['stream_url'] + else: + uri = ch_obj.set_uri(sid_data) + + # NOTE tvheadend supports '|' separated names in two attributes + # either 'group-title' or 'tvh-tags' + # if a ';' is used in group-title, tvheadend will use the + # entire string as a tag + groups = sid_data['namespace'] + inst_group = _config[config_section]['channel-group_name'] + if inst_group is not None: + groups += '|' + inst_group + if sid_data['group_tag']: + groups += '|' + '|'.join([sid_data['group_tag']]) + if sid_data['json']['HD']: + if sid_data['json']['group_hdtv']: + groups += '|' + sid_data['json']['group_hdtv'] + elif sid_data['json']['group_sdtv']: + groups += '|' + sid_data['json']['group_sdtv'] + + updated_chnum = utils.wrap_chnum( + str(sid_data['display_number']), sid_data['namespace'], + sid_data['instance'], _config) + service_name = ch_obj.set_service_name(sid_data) + fakefile.write( + '%s\n' % ( + record_marker + ':-1' + ' ' + + 'channelID="' + sid + '" ' + + 'tvg-num="' + updated_chnum + '" ' + + 'tvg-chno="' + updated_chnum + '" ' + + 'tvg-name="' + sid_data['display_name'] + '" ' + + 'tvg-id="' + sid + '" ' + + (('tvg-logo="' + sid_data['thumbnail'] + '" ') + if sid_data['thumbnail'] else '') + + 'group-title="' + groups + '",' + service_name + ) + ) + fakefile.write( + '%s\n' % ( + ( + uri + ) + ) + ) + return fakefile.getvalue() + + +def get_channels_json(_config, _base_url, _namespace, _instance, _plugins): + db = DBChannels(_config) + ch_obj = ChannelsURL(_config, _base_url) + ch_data = db.get_channels(_namespace, _instance) + return_json = '' + sids_processed = [] + for sid, sid_data_list in ch_data.items(): + for sid_data in sid_data_list: + if sid in sids_processed: + continue + sids_processed.append(sid) + if not sid_data['enabled']: + continue + if not _plugins.get(sid_data['namespace']): + continue + if not _plugins[sid_data['namespace']].enabled: + continue + if not _plugins[sid_data['namespace']] \ + .plugin_obj.instances[sid_data['instance']].enabled: + continue + config_section = utils.instance_config_section(sid_data['namespace'], sid_data['instance']) + if not _config[config_section]['enabled']: + continue + sids_processed.append(sid) + stream = _config[config_section]['player-stream_type'] + if stream == 'm3u8redirect': + uri = sid_data['json']['stream_url'] + else: + uri = ch_obj.set_uri(sid_data) + updated_chnum = utils.wrap_chnum( + str(sid_data['display_number']), sid_data['namespace'], + sid_data['instance'], _config) + return_json = return_json + ch_templates['jsonLineup'].format( + sid_data['json']['callsign'], + updated_chnum, + sid_data['display_name'], + uri, + sid_data['json']['HD']) + return_json = return_json + ',' + return "[" + return_json[:-1] + "]" + + +def get_channels_xml(_config, _base_url, _namespace, _instance, _plugins): + db = DBChannels(_config) + ch_obj = ChannelsURL(_config, _base_url) + ch_data = db.get_channels(_namespace, _instance) + return_xml = '' + sids_processed = [] + for sid, sid_data_list in ch_data.items(): + for sid_data in sid_data_list: + if sid in sids_processed: + continue + if not sid_data['enabled']: + continue + if not _plugins.get(sid_data['namespace']): + continue + if not _plugins[sid_data['namespace']].enabled: + continue + if not _plugins[sid_data['namespace']] \ + .plugin_obj.instances[sid_data['instance']].enabled: + continue + + config_section = utils.instance_config_section(sid_data['namespace'], sid_data['instance']) + if not _config[config_section]['enabled']: + continue + sids_processed.append(sid) + stream = _config[config_section]['player-stream_type'] + if stream == 'm3u8redirect': + uri = sid_data['json']['stream_url'] + uri = escape(uri) + else: + uri = escape(ch_obj.set_uri(sid_data)) + updated_chnum = utils.wrap_chnum( + str(sid_data['display_number']), sid_data['namespace'], + sid_data['instance'], _config) + return_xml = return_xml + ch_templates['xmlLineup'].format( + updated_chnum, + escape(sid_data['display_name']), + uri, + sid_data['json']['HD']) + return "" + return_xml + "" + + +class ChannelsURL: + + def __init__(self, _config, _base_url): + self.logger = logging.getLogger(__name__) + self.config = _config + self.base_url = _base_url + + def update_channels(self, _namespace, _query_data): + db = DBChannels(self.config) + ch_data = db.get_channels(_namespace, None) + results = 'Status Results
    ' + for key, values in _query_data.items(): + key_pair = key.split('-', 2) + uid = key_pair[0].replace('%2d', '-') + instance = key_pair[1] + name = key_pair[2] + value = values[0] + if name == 'enabled': + value = int(value) + + db_value = None + ch_db = None + for ch_db in ch_data[uid]: + if ch_db['instance'] == instance: + db_value = ch_db[name] + break + if value != db_value: + if value is None: + lookup_name = self.translate_main2json(name) + if lookup_name is not None: + value = ch_db['json'][lookup_name] + if name == 'display_number': + config_section = utils.instance_config_section(ch_db['namespace'], instance) + start_ch = self.config[config_section].get('channel-start_ch_num') + if start_ch > -1: + results += ''.join(['
  • ERROR: Starting Ch Number setting is not default (-1) [', uid, '][', instance, '][', name, '] not changed', '
  • ']) + continue + results += ''.join(['
  • Updated [', uid, '][', instance, '][', name, '] to ', str(value), '
  • ']) + ch_db[name] = value + if name == 'thumbnail': + thumbnail_size = self.get_thumbnail_size(value) + ch_db['thumbnail_size'] = thumbnail_size + db.update_channel(ch_db) + results += '

' + return results + + def translate_main2json(self, _name): + if _name == 'display_number': + return 'number' + elif _name == 'display_name': + return 'name' + elif _name == 'thumbnail': + return _name + else: + return None + + @handle_url_except() + def get_thumbnail_size(self, _thumbnail): + thumbnail_size = (0, 0) + if _thumbnail is None or _thumbnail == '': + return thumbnail_size + h = {'User-Agent': utils.DEFAULT_USER_AGENT, + 'Accept': '*/*', + 'Accept-Encoding': 'identity', + 'Connection': 'Keep-Alive' + } + req = urllib.request.Request(_thumbnail, headers=h) + with urllib.request.urlopen(req) as resp: + img_blob = resp.read() + fp = io.BytesIO(img_blob) + sz = len(img_blob) + thumbnail_size = get_image_size.get_image_size_from_bytesio(fp, sz) + return thumbnail_size + + def set_service_name(self, _sid_data): + """ + Returns the service name used to sync with the EPG channel name + """ + updated_chnum = utils.wrap_chnum( + str(_sid_data['display_number']), _sid_data['namespace'], + _sid_data['instance'], self.config) + if self.config['epg']['epg_channel_number']: + return updated_chnum + \ + ' ' + _sid_data['display_name'] + else: + return _sid_data['display_name'] + + def set_uri(self, _sid_data): + if self.config['epg']['epg_use_channel_number']: + updated_chnum = utils.wrap_chnum( + str(_sid_data['display_number']), _sid_data['namespace'], + _sid_data['instance'], self.config) + uri = '{}{}/{}/auto/v{}'.format( + 'http://', self.base_url, _sid_data['namespace'], updated_chnum) + else: + uri = '{}{}/{}/watch/{}'.format( + 'http://', self.base_url, _sid_data['namespace'], str(_sid_data['uid'])) + return uri diff --git a/lib/clients/channels/channels_form_html.py b/lib/clients/channels/channels_form_html.py new file mode 100644 index 0000000000000000000000000000000000000000..291ccbfedcab70d09fb6bce8b8276294682a1b9a --- /dev/null +++ b/lib/clients/channels/channels_form_html.py @@ -0,0 +1,456 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import lib.common.utils as utils +from lib.common.decorators import getrequest +from lib.common.decorators import postrequest +from lib.clients.channels.channels import ChannelsURL + + +@getrequest.route('/api/channelsform') +def get_channels_form_html(_webserver, _namespace=None, _sort_col=None, _sort_dir=None, filter_dict=None): + channels_form = ChannelsFormHTML(_webserver.channels_db, _webserver.config) + if _namespace is None: + name = _webserver.query_data['name'] + else: + name = _namespace + form = channels_form.get(name, _sort_col, _sort_dir, filter_dict) + _webserver.do_mime_response(200, 'text/html', form) + + +@postrequest.route('/api/channelsform') +def post_channels_html(_webserver): + namespace = _webserver.query_data['name'][0] + sort_col = _webserver.query_data['sort_col'][0] + sort_dir = _webserver.query_data['sort_dir'][0] + del _webserver.query_data['name'] + del _webserver.query_data['instance'] + del _webserver.query_data['sort_dir'] + del _webserver.query_data['sort_col'] + filter_dict = get_filter_data(_webserver.query_data) + + if sort_col is None: + cu = ChannelsURL(_webserver.config, _webserver.stream_url) + results = cu.update_channels(namespace, _webserver.query_data) + _webserver.do_mime_response(200, 'text/html', results) + else: + get_channels_form_html(_webserver, namespace, sort_col, sort_dir, filter_dict) + + +class ChannelsFormHTML: + + def __init__(self, _channels_db, _config): + self.db = _channels_db + self.namespace = None + self.config = _config + self.active_tab_name = None + self.num_of_channels = 0 + self.num_enabled = 0 + self.sort_column = None + self.sort_direction = None + self.ch_data = None + self.filter_dict = None + + def get(self, _namespace, _sort_col, _sort_dir, _filter_dict): + self.sort_column = _sort_col + self.sort_direction = _sort_dir + self.namespace = _namespace + self.filter_dict = _filter_dict + sort_data = self.get_db_sort_data(_sort_col, _sort_dir) + self.ch_data = self.db.get_sorted_channels(self.namespace, None, sort_data[0], sort_data[1]) + return ''.join([self.header, self.body]) + + def get_db_sort_data(self, _sort_col, _sort_dir): + if _sort_dir == 'sortdesc': + ascending = False + elif _sort_dir == 'sortasc': + ascending = True + else: + _sort_col = None + ascending = True + db_column2 = None + if _sort_col == 'enabled': + db_column1 = 'enabled' + db_column2 = 'instance' + elif _sort_col == 'instance': + db_column1 = 'instance' + elif _sort_col == 'num': + db_column1 = 'display_number' + elif _sort_col == 'name': + db_column1 = 'display_name' + elif _sort_col == 'group': + db_column1 = 'group_tag' + elif _sort_col == 'thumbnail': + db_column1 = 'thumbnail' + elif _sort_col == 'metadata': + db_column1 = 'HD' + db_column2 = 'callsign' + else: + db_column1 = None + return [[db_column1, ascending], [db_column2, ascending]] + + @property + def header(self): + return ''.join([ + '', + '', + '', + '', + '' + ]) + + @property + def form_header(self): + header_dir = { + 'enabled': 'sortnone', + 'instance': 'sortnone', + 'num': 'sortnone', + 'name': 'sortnone', + 'group': 'sortnone', + 'thumbnail': 'sortnone', + 'metadata': 'sortnone' + } + header_dir[self.sort_column] = self.sort_direction + + return ''.join([ + '', + '', + '', + '', + '', + '
Total Unique Channels = ', str(self.num_of_channels), '
Total Enabled Unique Channels = ', str(self.num_enabled), '', + '', + '
', + '', + '', + '', + '', + '', + '', + '', + '', + '' + + '', + + '', + + '', + '', + '', + '', + '', + ]) + + def get_filter_enable_checkbox(self, _name): + name = _name + "-mi" + if self.filter_dict is None: + return '' + elif name in self.filter_dict: + return '' + else: + return '' + + def get_filter_textbox(self, _name): + name = _name + "-text" + if self.filter_dict is not None and self.filter_dict[name] is not None: + return '' + else: + return '' + + def get_filter_text_chkbox(self, _name): + name = _name + "-checkbox" + if self.filter_dict is None: + return '' + elif name in self.filter_dict: + return '' + else: + return '' + + @property + def form(self): + t = self.table + forms_html = ''.join(['', + self.form_header, t, '']) + return forms_html + + @property + def table(self): + table_html = '' + sids_processed = {} + for sid_data in self.ch_data: + sid = sid_data['uid'] + instance = sid_data['instance'] + if sid_data['enabled']: + enabled = 'checked' + enabled_status = "enabled" + else: + enabled = '' + enabled_status = "disabled" + if sid in sids_processed.keys(): + if sid_data['enabled']: + enabled_status = "duplicate" + else: + enabled_status = "duplicate_disabled" + else: + sids_processed[sid] = sid_data['enabled'] + + if sid_data['json']['HD']: + quality = 'HD' + else: + quality = 'SD' + if 'VOD' in sid_data['json']: + if sid_data['json']['VOD']: + vod = 'VOD' + else: + # Not sure what to call not VOD? + vod = 'Live' + else: + vod = '' + + max_image_size = self.lookup_config_size() + if sid_data['thumbnail_size'] is not None: + image_size = sid_data['thumbnail_size'] + thumbnail_url = utils.process_image_url(self.config, sid_data['thumbnail']) + if thumbnail_url: + if max_image_size is None: + display_image = ''.join(['']) + elif max_image_size == 0: + display_image = '' + else: + if image_size[0] < max_image_size: + img_width = str(image_size[0]) + else: + img_width = str(max_image_size) + display_image = ''.join(['']) + else: + display_image = '' + image_size = 'UNK' + else: + display_image = '' + image_size = 'UNK' + + if sid_data['json']['groups_other'] is None: + groups_other = '' + else: + groups_other = str(sid_data['json']['groups_other']) + + if sid_data['json']['thumbnail_size'] is not None: + original_size = sid_data['json']['thumbnail_size'] + else: + original_size = 'UNK' + row = ''.join([ + '', + '', + '', + '', + '', + '', + '', + '' + ]) + table_html += row + self.num_of_channels = len(sids_processed.keys()) + self.num_enabled = sum(x for x in sids_processed.values()) + return ''.join([table_html, '
', + '
', + '', + '', + '', instance, '', + self.get_input_text(sid_data, sid, instance, 'display_number'), '', + self.get_input_text(sid_data, sid, instance, 'display_name'), '', + self.get_input_text(sid_data, sid, instance, 'group_tag'), '
', + self.get_input_text(sid_data, sid, instance, 'thumbnail'), + '
', + display_image, + '
', + 'size=', str(image_size), ' original_size=', str(original_size), + '
', quality, ' ', vod, ' ', + sid_data['json']['callsign'], ' ', sid, '
', + groups_other, + '
']) + + def get_input_text(self, _sid_data, _sid, _instance, _title): + if _sid_data[_title] is not None: + size = len(_sid_data[_title]) + if size > 25: + rows = 1 + if size > 70: + rows = 2 + return ''.join(['']) + else: + if size > 20: + size = 20 + elif size < 3: + size = 3 + elif size < 13: + size = size + 1 + return ''.join(['']) + else: + return ''.join(['']) + + def get_input_name(self, _sid, _instance, _title): + _sid = _sid.replace('-', '%2d') + return ''.join([_sid, '-', _instance, '-', _title]) + + @property + def body(self): + return ''.join([ + '
', + self.form, + '

Clearing any field and saving will revert to the default value. Sorting ', + 'a column will clear any filters applied. Help is provided on the column titles. ', + 'First column displays the status of the channel: either enabled, disabled or duplicate', + ' (enabled or disabled). The thumbnail field must have an entry; not using the ', + 'thumbnail is a configuration parameter under Settings - Clients - EPG.', + ' Thumbnail filtering is only on the URL.', + ' The size of the thumbnail presented in the table is set using the configuration', + ' parameter under Settings - Internal - Channels.

', + '
']) + + def lookup_config_size(self): + size_text = self.config['channels']['thumbnail_size'] + if size_text == 'None': + return 0 + elif size_text == 'Tiny(16)': + return 16 + elif size_text == 'Small(48)': + return 48 + elif size_text == 'Medium(128)': + return 128 + elif size_text == 'Large(180)': + return 180 + elif size_text == 'X-Large(270)': + return 270 + elif size_text == 'Full-Size': + return None + else: + return None + + +def get_filter_data(query_data): + filter_names_list = [ + 'enabled-mi', 'duplicate-mi', 'disabled-mi', 'duplicate_disabled-mi', + 'instance-checkbox', 'instance-text', 'num-text', 'num-checkbox', 'name-text', 'name-checkbox', + 'group-text', + 'group-checkbox', 'thumbnail-text', 'thumbnail-checkbox', + 'metadata-text', 'metadata-checkbox'] + filter_dict = {} + for name in filter_names_list: + try: + filter_dict[name] = query_data[name][0] + del query_data[name] + except KeyError: + pass + return filter_dict diff --git a/lib/clients/channels/channels_html.py b/lib/clients/channels/channels_html.py new file mode 100644 index 0000000000000000000000000000000000000000..2079ff83fbc77707406a53d024f871580193b3e2 --- /dev/null +++ b/lib/clients/channels/channels_html.py @@ -0,0 +1,102 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +from lib.common.decorators import getrequest + + +@getrequest.route('/api/channels') +def get_channels_html(_webserver): + channels_html = ChannelsHTML(_webserver.channels_db) + html = channels_html.get() + _webserver.do_mime_response(200, 'text/html', html) + + +class ChannelsHTML: + + def __init__(self, _channels_db): + self.db = _channels_db + self.config = None + self.active_tab_name = None + self.tab_names = None + + def get(self): + self.tab_names = self.get_channels_tabs() + return ''.join([self.header, self.body]) + + @property + def header(self): + return ''.join([ + '', + '', + '', + 'Channel Editor', + '', + '', + '', + '', + '', + '' + ]) + + @property + def title(self): + return ''.join([ + '
', + '

Channel Editor

' + ]) + + @property + def tabs(self): + tabs_html = ''.join([ + '
    ', + '
  • ', + '
  • ' + ]) + for name in self.tab_names.keys(): + tabs_html = ''.join([ + tabs_html, + '
  • ', + 'view_list', + '', + name, '
  • ' + ]) + self.active_tab_name = name + tabs_html = ''.join([tabs_html, '
']) + return tabs_html + + @property + def body(self): + return ''.join([ + self.title, + self.tabs, + '
Select Tab to Edit
' + ]) + + def get_channels_tabs(self): + ch_list = self.db.get_channel_names() + return_list = {} + for ch_names in ch_list: + return_list[ch_names['namespace']] = None + return return_list diff --git a/lib/clients/channels/templates.py b/lib/clients/channels/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..0aaaaf8853b26ea09ca5c0c894e47b30055faf27 --- /dev/null +++ b/lib/clients/channels/templates.py @@ -0,0 +1,38 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +ch_templates = { + + 'jsonLineup': + """{{ + "CallSign": "{}", + "GuideNumber": "{}", + "GuideName": "{}", + "URL": "{}", + "HD": {} + }}""", + + 'xmlLineup': + """ + {} + {} + {} + {} + """ + +} diff --git a/lib/clients/epg2xml.py b/lib/clients/epg2xml.py new file mode 100644 index 0000000000000000000000000000000000000000..1b8f239f1599d12abc885162b6f55984a0a40a5a --- /dev/null +++ b/lib/clients/epg2xml.py @@ -0,0 +1,375 @@ +# pylama:ignore=E722 +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import traceback +import datetime +import errno +import logging +import re +import xml.dom.minidom as minidom +from xml.etree import ElementTree + +import lib.common.utils as utils +import lib.tvheadend.epg_category as epg_category +from lib.common.decorators import getrequest +from lib.db.db_channels import DBChannels +from lib.db.db_epg import DBepg +from lib.web.pages.templates import web_templates + + +@getrequest.route('/xmltv.xml') +def xmltv_xml(_webserver): + try: + epg = EPG(_webserver) + epg.get_epg_xml(_webserver) + except MemoryError as e: + _webserver.do_mime_response( + 501, 'text/html', + web_templates['htmlError'].format('501 - MemoryError: {}'.format(e))) + + +class EPG: + # https://github.com/XMLTV/xmltv/blob/master/xmltv.dtd + def __init__(self, _webserver): + self.logger = logging.getLogger(__name__) + self.webserver = _webserver + self.config = _webserver.plugins.config_obj.data + self.epg_db = DBepg(self.config) + self.channels_db = DBChannels(self.config) + self.plugins = _webserver.plugins + self.namespace = _webserver.query_data['name'] + self.instance = _webserver.query_data['instance'] + self.tv_tag = False + self.today = datetime.datetime.utcnow().date() + self.prog_processed = [] + + def get_next_epg_day(self): + is_enabled = False + day_data = None + ns = None + inst = None + day = None + while not is_enabled: + day_data, ns, inst, day = self.epg_db.get_next_row() + if day_data is None: + break + if day < self.today: + continue + config_section = utils.instance_config_section(ns, inst) + + if not self.config.get(ns.lower()) \ + or not self.config[ns.lower()]['enabled'] \ + or not self.config.get(config_section) \ + or not self.config[config_section]['enabled'] \ + or not self.config[config_section].get('epg-enabled'): + continue + is_enabled = True + return day_data, ns, inst, day + + def get_epg_xml(self, _webserver): + xml_out = None + + if self.namespace is not None \ + and not self.plugins.plugins.get(self.namespace): + _webserver.do_mime_response( + 501, 'text/html', + web_templates['htmlError'].format('501 - Invalid Namespace: {}'.format(self.namespace))) + return + + try: + _webserver.do_dict_response({ + 'code': 200, + 'headers': {'Content-type': 'application/xml; Transfer-Encoding: chunked'}, + 'text': None}) + xml_out = self.gen_header_xml() + channel_list = self.channels_db.get_channels(self.namespace, self.instance) + self.gen_channel_xml(xml_out, channel_list) + self.write_xml(xml_out, keep_xml_prolog=True) + xml_out = None + + self.epg_db.init_get_query(self.namespace, self.instance) + + day_data, ns, inst, day = self.get_next_epg_day() + self.logger.debug('Processing EPG data {}:{} {}' + .format(ns, inst, day)) + self.prog_processed = [] + while day_data: + xml_out = EPG.gen_minimal_header_xml() + self.gen_program_xml(xml_out, day_data, channel_list, ns, inst) + self.write_xml(xml_out) + xml_out.clear() + day_data, ns, inst, day = self.get_next_epg_day() + self.logger.debug('Processing EPG data {}:{} {}' + .format(ns, inst, day)) + day_data = None + self.epg_db.close_query() + self.webserver.wfile.write(b'\r\n') + self.webserver.wfile.flush() + except MemoryError as e: + self.logger.error('MemoryError parsing large xml') + raise e + except IOError as ex: + # Check we hit a broken pipe when trying to write back to the client + if ex.errno in [errno.EPIPE, errno.ECONNABORTED, errno.ECONNRESET, errno.ECONNREFUSED]: + # Normal process. Client request end of stream + self.logger.info('Connection dropped by client {}' + .format(ex)) + xml_out.clear() + return + else: + self.logger.error('{}{}'.format( + 'UNEXPECTED EXCEPTION=', ex)) + raise + + xml_out = None # clear to help garbage collection + + def write_xml(self, _xml, keep_xml_prolog=False): + if self.config['epg']['epg_prettyprint']: + if not keep_xml_prolog: + epg_dom = minidom.parseString(ElementTree.tostring(_xml, encoding='UTF-8', method='xml')).toprettyxml() + if epg_dom.endswith('\n'): + epg_dom = epg_dom.replace('\n', '', 1) + epg_dom = epg_dom.replace('', '', 1) + else: + epg_dom = '' + else: + epg_dom = minidom.parseString(ElementTree.tostring(_xml, encoding='UTF-8', method='xml')).toprettyxml() + epg_dom = epg_dom.replace('\n','\n\n',1) + if epg_dom.endswith('\n'): + epg_dom = re.sub('\n$', '', epg_dom) + else: + epg_dom = re.sub('"/>\n$', '">', epg_dom) + self.webserver.wfile.write(epg_dom.encode()) + else: + if not keep_xml_prolog: + epg_dom = ElementTree.tostring(_xml) + if epg_dom.endswith(b''): + epg_dom = b'' + else: + epg_dom = epg_dom.replace(b'', b'', 1) + epg_dom = epg_dom.replace(b'', b'', 1) + else: + epg_dom = b'' + epg_dom = epg_dom + ElementTree.tostring(_xml) + if epg_dom.endswith(b''): + epg_dom = re.sub(b'$', b'', epg_dom) + else: + epg_dom = re.sub(b'" />$', b'">', epg_dom) + self.webserver.wfile.write(epg_dom + b'\r\n') + epg_dom = None # clear to help garbage collection + return True + + def gen_channel_xml(self, _et_root, _channel_list): + sids_processed = [] + for sid, sid_data_list in _channel_list.items(): + if sid in sids_processed: + continue + sids_processed.append(sid) + for ch_data in sid_data_list: + if not ch_data['enabled']: + continue + config_section = utils.instance_config_section(ch_data['namespace'], ch_data['instance']) + if not self.config.get(ch_data['namespace'].lower()) \ + or not self.config[ch_data['namespace'].lower()]['enabled'] \ + or not self.config.get(config_section) \ + or not self.config[config_section]['enabled'] \ + or not self.config[config_section].get('epg-enabled'): + continue + + updated_chnum = utils.wrap_chnum( + ch_data['display_number'], ch_data['namespace'], + ch_data['instance'], self.config) + if self.config['epg'].get('epg_add_plugin_to_channel_id'): + ch_ref = ch_data['namespace'] + '-' + else: + ch_ref = '' + if self.config['epg'].get('epg_use_channel_number'): + ch_ref += updated_chnum + else: + ch_ref += sid + c_out = EPG.sub_el(_et_root, 'channel', id=ch_ref) + + EPG.sub_el(c_out, 'display-name', _text='%s %s' % + (updated_chnum, ch_data['display_name'])) + EPG.sub_el(c_out, 'display-name', _text=ch_data['display_name']) + EPG.sub_el(c_out, 'display-name', _text=ch_data['json']['callsign']) + EPG.sub_el(c_out, 'display-name', _text='%s %s' % + (updated_chnum, ch_data['json']['callsign'])) + EPG.sub_el(c_out, 'lcn', _text='%s' % + (updated_chnum)) + if self.config['epg']['epg_channel_icon'] and ch_data['thumbnail'] is not None: + EPG.sub_el(c_out, 'icon', src=ch_data['thumbnail']) + break + return _et_root + + def gen_program_xml(self, _et_root, _prog_list, _channel_list, _ns, _inst): + + for prog_data in _prog_list: + proginfo = prog_data['start'] + prog_data['channel'] + if proginfo in self.prog_processed: + continue + skip = False + try: + for ch_data in _channel_list[prog_data['channel']]: + if ch_data['namespace'] == _ns \ + and ch_data['instance'] == _inst: + if not ch_data['enabled']: + skip = True + break + config_section = utils.instance_config_section(ch_data['namespace'], ch_data['instance']) + if not self.config[ch_data['namespace'].lower()]['enabled']: + skip = True + break + if not self.config[config_section]['enabled']: + skip = True + break + if not self.config[config_section]['epg-enabled']: + skip = True + break + except KeyError as ex: + skip = True + + if skip: + continue + self.prog_processed.append(proginfo) + + if self.config['epg'].get('epg_add_plugin_to_channel_id'): + ch_ref = ch_data['namespace'] + '-' + else: + ch_ref = '' + if self.config['epg'].get('epg_use_channel_number'): + ch_data = _channel_list[prog_data['channel']][0] + updated_chnum = utils.wrap_chnum( + ch_data['display_number'], ch_data['namespace'], + ch_data['instance'], self.config) + ch_ref += updated_chnum + else: + ch_ref += prog_data['channel'] + prog_out = EPG.sub_el(_et_root, 'programme', + start=prog_data['start'], + stop=prog_data['stop'], + channel=ch_ref) + if prog_data['title']: + EPG.sub_el(prog_out, 'title', lang='en', _text=prog_data['title']) + if prog_data['subtitle']: + EPG.sub_el(prog_out, 'sub-title', lang='en', _text=prog_data['subtitle']) + descr_add = '' + if self.config['epg']['description'] == 'extend': + if prog_data['formatted_date']: + descr_add += '(' + prog_data['formatted_date'] + ') ' + if prog_data['genres']: + descr_add += ' / '.join(prog_data['genres']) + ' / ' + if prog_data['se_common']: + descr_add += prog_data['se_common'] + elif prog_data['episode']: + descr_add += 'E' + str(prog_data['episode']) + descr_add += '\n' + prog_data['desc'] + elif self.config['epg']['description'] == 'brief': + descr_add = prog_data['short_desc'] + elif self.config['epg']['description'] == 'normal': + descr_add = prog_data['desc'] + else: + self.logger.warning('Config value [epg][description] is invalid: ' + + self.config['epg']['description']) + EPG.sub_el(prog_out, 'desc', lang='en', _text=descr_add) + + if prog_data['video_quality']: + video_out = EPG.sub_el(prog_out, 'video') + EPG.sub_el(video_out, 'quality', prog_data['video_quality']) + + if prog_data['air_date']: + EPG.sub_el(prog_out, 'date', + _text=prog_data['air_date']) + + EPG.sub_el(prog_out, 'length', units='minutes', _text=str(prog_data['length'])) + + if prog_data['genres']: + for f in prog_data['genres']: + if self.config['epg']['genre'] == 'normal': + pass + elif self.config['epg']['genre'] == 'tvheadend': + if f in epg_category.TVHEADEND.keys(): + f = epg_category.TVHEADEND[f] + else: + self.logger.warning('Config value [epg][genre] is invalid: ' + + self.config['epg']['genre']) + EPG.sub_el(prog_out, 'category', lang='en', _text=f.strip()) + + if prog_data['icon'] and self.config['epg']['epg_program_icon']: + EPG.sub_el(prog_out, 'icon', src=prog_data['icon']) + + if prog_data['actors'] or prog_data['directors']: + r = ElementTree.SubElement(prog_out, 'credits') + if prog_data['directors']: + for actor in prog_data['directors']: + EPG.sub_el(r, 'director', _text=actor) + if prog_data['actors']: + for actor in prog_data['actors']: + EPG.sub_el(r, 'actor', _text=actor) + + if prog_data['rating']: + r = ElementTree.SubElement(prog_out, 'rating') + EPG.sub_el(r, 'value', _text=prog_data['rating']) + + if prog_data['se_common']: + EPG.sub_el(prog_out, 'episode-num', system='common', + _text=prog_data['se_common']) + EPG.sub_el(prog_out, 'episode-num', system='SxxExx', + _text=prog_data['se_common']) + if prog_data['se_progid']: + EPG.sub_el(prog_out, 'episode-num', system='dd_progid', + _text=prog_data['se_progid']) + if prog_data['se_xmltv_ns']: + EPG.sub_el(prog_out, 'episode-num', system='xmltv_ns', + _text=prog_data['se_xmltv_ns']) + if prog_data['is_new']: + EPG.sub_el(prog_out, 'new') + else: + EPG.sub_el(prog_out, 'previously-shown') + if prog_data['cc']: + EPG.sub_el(prog_out, 'subtitles', type='teletext') + if prog_data['premiere']: + EPG.sub_el(prog_out, 'premiere') + + def gen_header_xml(self): + if self.namespace is None: + website = utils.CABERNET_URL + name = utils.CABERNET_ID + else: + website = self.plugins.plugins[self.namespace].plugin_settings['website'] + name = self.plugins.plugins[self.namespace].plugin_settings['name'] + + xml_out = ElementTree.Element('!DOCTYPE') + xml_out = ElementTree.Element('tv') + xml_out.set('source-info-url', website) + xml_out.set('source-info-name', name) + xml_out.set('generator-info-name', utils.CABERNET_ID) + xml_out.set('generator-info-url', utils.CABERNET_URL) + return xml_out + + @staticmethod + def gen_minimal_header_xml(): + return ElementTree.Element('tv') + + @staticmethod + def sub_el(_parent, _name, _text=None, **kwargs): + el = ElementTree.SubElement(_parent, _name, **kwargs) + if _text: + el.text = _text + return el diff --git a/lib/clients/hdhr/__init__.py b/lib/clients/hdhr/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2caff27f62d023f3eae5fd452334a08e4bdc199e --- /dev/null +++ b/lib/clients/hdhr/__init__.py @@ -0,0 +1 @@ +import lib.clients.hdhr.hdhr_urls diff --git a/lib/clients/hdhr/hdhr_server.py b/lib/clients/hdhr/hdhr_server.py new file mode 100644 index 0000000000000000000000000000000000000000..5aaaec282575d32699e3ecac104dd1e599587f42 --- /dev/null +++ b/lib/clients/hdhr/hdhr_server.py @@ -0,0 +1,432 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import ipaddress +import logging +import random +import socket +import string +import struct +import sys +import zlib +from ipaddress import IPv4Network +from ipaddress import IPv4Address +from multiprocessing import Process +from threading import Thread + +import lib.common.utils as utils + +HDHR_PORT = 65001 +HDHR_ADDR = '224.0.0.255' # multicast to local addresses only +# HDHR_ADDR = '239.255.255.250' +SERVER_ID = 'HDHR3' +HDHOMERUN_TYPE_DISCOVER_REQ = 2 +HDHOMERUN_TYPE_DISCOVER_RSP = 3 +HDHOMERUN_TYPE_GETSET_REQ = 4 +HDHOMERUN_TYPE_GETSET_RSP = 5 +HDHOMERUN_GETSET_NAME = 3 +HDHOMERUN_GETSET_VALUE = 4 +HDHOMERUN_ERROR_MESSAGE = 5 +HDHOMERUN_GETSET_LOCKKEY = 21 +START_SEND_UDP_ATSC_PKTS = 1 +STOP_SEND_UDP_ATSC_PKTS = 0 +HDHOMERUN_BASE_URL = 0x2A +HDHOMERUN_LINEUP_URL = 0x27 +# HDHOMERUN_DEVICE_AUTH_STR = 0x2B +# HDHOMERUN_DEVICE_TYPE_WILDCARD = 0xFFFFFFFF +HDHOMERUN_DEVICE_TYPE_TUNER = 0x00000001 +# HDHOMERUN_DEVICE_ID_WILDCARD = 0xFFFFFFFF + +msgs = { + 'lockedErrMsg': + """ERROR: resource locked by {}""", + 'scanErrMsg': + """ERROR: tuner busy""", +} + +tuner_status_msg = { + 'Idle': b'ch=none lock=none ss=0 snq=0 seq=0 bps=0 pps=0', + 'Stream': b'ch=8vsb:183000000 lock=8vsb ss=98 snq=80 seq=90 bps=12345678 pps=1234', +} + +logger = None + + +def hdhr_process(config, _tuner_queue): + global logger + utils.logging_setup(config['paths']) + logger = logging.getLogger(__name__) + if config['hdhomerun']['udp_netmask'] is None: + logger.error('Config setting [hdhomerun][udp_netmask] required. Exiting hdhr service') + return + + try: + IPv4Network(config['hdhomerun']['udp_netmask']) + except (ipaddress.AddressValueError, ValueError) as err: + logger.error( + 'Illegal value in [hdhomerun][udp_netmask]. ' + 'Format must be #.#.#.#/#. Exiting hdhr service. ERROR: {}'.format(err)) + return + + hdhr = HDHRServer(config, _tuner_queue) + # startup the multicast thread first which will exit when this function exits + p_multi = Process(target=hdhr.run_multicast, args=(config["web"]["bind_ip"],)) + p_multi.daemon = True + p_multi.start() + + # startup the standard tcp listener, but have this hang the process + # the socket listener will terminate from main.py when the process is stopped + hdhr.run_listener(config["web"]["bind_ip"]) + logger.info('hdhr_processing terminated') + + +def hdhr_validate_device_id(_device_id): + global logger + hex_digits = set(string.hexdigits) + if len(_device_id) != 8: + logger.error('ERROR: HDHR Device ID must be 8 hexidecimal values') + return False + if not all(c in hex_digits for c in _device_id): + logger.error('ERROR: HDHR Device ID characters must all be hex (0-A)') + return False + device_id_bin = bytes.fromhex(_device_id) + cksum_lookup = [0xA, 0x5, 0xF, 0x6, 0x7, 0xC, 0x1, 0xB, 0x9, 0x2, 0x8, 0xD, 0x4, 0x3, 0xE, 0x0] + device_id_int = int.from_bytes(device_id_bin, byteorder='big') + cksum = 0 + cksum ^= cksum_lookup[(device_id_int >> 28) & 0x0F] + cksum ^= (device_id_int >> 24) & 0x0F + cksum ^= cksum_lookup[(device_id_int >> 20) & 0x0F] + cksum ^= (device_id_int >> 16) & 0x0F + cksum ^= cksum_lookup[(device_id_int >> 12) & 0x0F] + cksum ^= (device_id_int >> 8) & 0x0F + cksum ^= cksum_lookup[(device_id_int >> 4) & 0x0F] + cksum ^= (device_id_int >> 0) & 0x0F + return cksum == 0 + + +# given a device id, will adjust the last 4 bits to make it valid and return the integer value +def hdhr_get_valid_device_id(_device_id): + global logger + hex_digits = set(string.hexdigits) + if len(_device_id) != 8: + logger.error('ERROR: HDHR Device ID must be 8 hexadecimal values') + return 0 + if not all(c in hex_digits for c in _device_id): + logger.error('ERROR: HDHR Device ID characters must all be hex (0-A)') + return 0 + device_id_bin = bytes.fromhex(_device_id) + cksum_lookup = [0xA, 0x5, 0xF, 0x6, 0x7, 0xC, 0x1, 0xB, 0x9, 0x2, 0x8, 0xD, 0x4, 0x3, 0xE, 0x0] + device_id_int = int.from_bytes(device_id_bin, byteorder='big') + cksum = 0 + cksum ^= cksum_lookup[(device_id_int >> 28) & 0x0F] + cksum ^= (device_id_int >> 24) & 0x0F + cksum ^= cksum_lookup[(device_id_int >> 20) & 0x0F] + cksum ^= (device_id_int >> 16) & 0x0F + cksum ^= cksum_lookup[(device_id_int >> 12) & 0x0F] + cksum ^= (device_id_int >> 8) & 0x0F + cksum ^= cksum_lookup[(device_id_int >> 4) & 0x0F] + new_dev_id = (device_id_int & 0xFFFFFFF0) + cksum + return struct.pack('>I', new_dev_id).hex().upper() + + +def hdhr_gen_device_id(): + baseid = '105' + ''.join(random.choice('0123456789ABCDEF') for _ in range(4)) + '0' + return hdhr_get_valid_device_id(baseid) + + +class HDHRServer: + """A class implementing a HDHR server. The notify_received and + searchReceived methods are called when the appropriate type of + datagram is received by the server.""" + known = {} + + def __init__(self, _config, _tuner_queue): + self.config = _config + self.logger = logging.getLogger(__name__ + '_tcp') + self.tuner_queue = _tuner_queue + self.sock_multicast = None + self.sock_listener = None + self._t = None + self.tuners = {} + for area, area_data in self.config.items(): + if 'player-tuner_count' in area_data.keys(): + self.tuners[area] = dict.fromkeys(range(self.config[area]['player-tuner_count'])) + for i in range(self.config[area]["player-tuner_count"]): + self.tuners[area][i] = { + 'channel': None, + 'status': 'Idle' + } + + def run_listener(self, _bind_ip=''): + self.logger.info('TCP: Starting HDHR TCP listener server') + self.sock_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_address = (_bind_ip, HDHR_PORT) + self.sock_listener.bind(server_address) + self.sock_listener.listen(3) + + self._t = Thread(target=self.process_queue, + args=(self.tuner_queue,)) + self._t.daemon = True + self._t.start() + + while True: + # wait for a connection + connection, client_address = self.sock_listener.accept() + t_conn = Thread(target=self.process_client_connection, args=(connection, client_address,)) + t_conn.daemon = True + t_conn.start() + + def process_client_connection(self, _connection, _address): + # multi-threading multiple clients talking to the device at one time + # buffer must be large enough to hold a full rcvd packets + self.logger.debug('TCP: New connection established {}'.format(_address)) + try: + while True: + msg = _connection.recv(1316) + if not msg: + # client disconnect + self.logger.debug('TCP: Client terminated connection {}'.format(_address)) + break + self.logger.debug('TCP: data rcvd={}'.format(msg)) + frame_type = HDHRServer.get_frame_type(msg) + if frame_type == HDHOMERUN_TYPE_GETSET_REQ: + req_dict = self.parse_getset_request(msg) + response = self.create_getset_response(req_dict, _address) + if response is not None: + self.logger.debug('TCP: Sending response={}'.format(response)) + _connection.sendall(response) + else: + self.logger.error('TCP: Unknown frame/message type from {} type={}'.format(_address, frame_type)) + finally: + _connection.close() + + def process_queue(self, _queue): + while True: + queue_item = _queue.get() + # queue item has a command and arguments which are based on the command. + self.tuners[queue_item['namespace'].lower()][queue_item['tuner']] = { + 'channel': queue_item['channel'], + 'status': queue_item['status'] + } + + @staticmethod + def get_frame_type(_msg): + """ + Get the type of message requested + :param _msg: + :return: + """ + # msg is in the first 2 bytes of the string + (frame_type,) = struct.unpack('>H', _msg[:2]) + return frame_type + + @staticmethod + def gen_err_response(_frame_type, _tag, _text): + # This is a tag type of HDHOMERUN_ERROR_MESSAGE + # does not include the crc + msg = msgs[_tag].format(*_text).encode() + tag = utils.set_u8(HDHOMERUN_ERROR_MESSAGE) + err_resp = utils.set_str(msg, True) + msg_len = utils.set_u16(len(tag) + len(err_resp)) + response = _frame_type + msg_len + tag + err_resp + return response + + def create_getset_response(self, _req_dict, _address): + (host, port) = _address + frame_type = utils.set_u16(HDHOMERUN_TYPE_GETSET_RSP) + name = _req_dict[HDHOMERUN_GETSET_NAME] + name_str = name.decode('utf-8') + # if HDHOMERUN_GETSET_VALUE in _req_dict.keys(): + # value = _req_dict[HDHOMERUN_GETSET_VALUE] + # else: + # value = None + + if name == b'/sys/model': + # required to id the device + name_resp = utils.set_u8(HDHOMERUN_GETSET_NAME) + utils.set_str(name, True) + value_resp = utils.set_u8(HDHOMERUN_GETSET_VALUE) + utils.set_str(b'hdhomerun4_atsc', True) + msg_len = utils.set_u16(len(name_resp) + len(value_resp)) + response = frame_type + msg_len + name_resp + value_resp + x = zlib.crc32(response) + crc = struct.pack('= len(_msg) - 4: + return None, None, None + (msg_type, length) = struct.unpack('BB', _msg[_offset:_offset + 2]) + _offset += 2 + (value,) = struct.unpack('%ds' % (length - 1), _msg[_offset:_offset + length - 1]) + _offset += length + return msg_type, value, _offset + + def run_multicast(self, _bind_ip=''): + utils.logging_setup(self.config['paths']) + self.logger = logging.getLogger(__name__ + '_udp') + self.logger.info('UDP: Starting HDHR multicast server') + self.sock_multicast = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.sock_multicast.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + + self.sock_multicast.bind(('0.0.0.0', HDHR_PORT)) + mreq = struct.pack('4sl', socket.inet_aton(HDHR_ADDR), socket.INADDR_ANY) + self.sock_multicast.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + self.sock_multicast.settimeout(2) + + while True: + try: + data, addr = self.sock_multicast.recvfrom(1024) + self.datagram_received(data, addr) + except socket.timeout: + continue + + def datagram_received(self, _data, _host_port): + """Handle a received multicast datagram.""" + + (host, port) = _host_port + if self.config['hdhomerun']['udp_netmask'] is None: + is_allowed = True + else: + try: + net = IPv4Network(self.config['hdhomerun']['udp_netmask']) + except (ipaddress.AddressValueError, ValueError) as err: + self.logger.error( + 'Illegal value in [hdhomerun][udp_netmask]. ' + 'Format must be #.#.#.#/#. Exiting hdhr service. ERROR: {}'.format(err)) + sys.exit(1) + is_allowed = IPv4Address(host) in net + + if not is_allowed: + return + + self.logger.debug('UDP: from {}:{}'.format(host, port)) + try: + (frame_type, msg_len, device_type, sub_dt_len, sub_dt, device_id, sub_did_len, sub_did) = \ + struct.unpack('>HHBBIBBI', _data[0:-4]) + # (crc,) = struct.unpack(' 100: + _webserver.scan_state = 100 + num_of_channels = len(_webserver.channels_db.get_channels(_webserver.query_data['name'], None, True)) + return_json = hdhr_templates['jsonLineupStatusScanning'].format( + _webserver.scan_state, + int(num_of_channels * _webserver.scan_state / 100)) + + if _webserver.scan_state == 100: + _webserver.scan_state = -1 + _webserver.update_scan_status(_webserver.query_data['name'], 'Idle') + _webserver.do_mime_response(200, 'application/json', return_json) + + +@postrequest.route('/lineup.post') +def lineup_post(_webserver): + if _webserver.query_data['scan'] == 'start': + _webserver.scan_state = 0 + _webserver.update_scan_status(_webserver.query_data['name'], 'Scan') + _webserver.do_mime_response(200, 'text/html') + elif _webserver.query_data['scan'] == 'abort': + _webserver.do_mime_response(200, 'text/html') + _webserver.scan_state = -1 + _webserver.update_scan_status(_webserver.query_data['name'], 'Idle') + else: + _webserver.logger.warning("Unknown scan command " + _webserver.query_data['scan']) + _webserver.do_mime_response( + 400, 'text/html', + web_templates['htmlError'].format( + _webserver.query_data['scan'] + ' is not a valid scan command')) diff --git a/lib/clients/hdhr/templates.py b/lib/clients/hdhr/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..058ee76f0b7efcf29e231bbf2fbc8fe94902c4e9 --- /dev/null +++ b/lib/clients/hdhr/templates.py @@ -0,0 +1,71 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +hdhr_templates = { + + 'jsonDiscover': + """{{ + "FriendlyName": "{0}", + "ModelNumber": "{1}", + "FirmwareName": "{2}", + "FirmwareVersion": "{3}", + "DeviceID": "{4}", + "TunerCount": {5}, + "BaseURL": "http://{6}{7}", + "LineupURL": "http://{6}{7}/lineup.json" + }}""", + + 'jsonLineupStatusScanning': + """{{ + "ScanInProgress":1, + "Progress":{}, + "Found":{} + }}""", + + 'jsonLineupStatusIdle': + """{ + "ScanInProgress":0, + "ScanPossible":1, + "Source":"Antenna", + "SourceList":["Antenna"] + }""", + + 'xmlDevice': + """ + + + 1 + 0 + + + DMS-1.50 + urn:schemas-upnp-org:device:MediaServer:1 + {0} HDHomeRun + / + Silicondust + {4} + {0} + {0} + {1} + {4} + {2} + uuid:{3} + + """, + +} diff --git a/lib/clients/ssdp/ssdp_server.py b/lib/clients/ssdp/ssdp_server.py new file mode 100644 index 0000000000000000000000000000000000000000..99bcaa75e8aa5366c0af94ca771c7dc800d605ea --- /dev/null +++ b/lib/clients/ssdp/ssdp_server.py @@ -0,0 +1,281 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import ipaddress +import logging +import random +import socket +import struct +import sys +from email.utils import formatdate +from errno import ENOPROTOOPT +from ipaddress import IPv4Network +from ipaddress import IPv4Address + +import lib.common.utils as utils + +SSDP_PORT = 1900 +SSDP_ADDR = '239.255.255.250' +SERVER_ID = 'HDHomeRun/1.0 UPnP/1.0' + + +def ssdp_process(config): + ssdp = SSDPServer(config) + ssdp.register('local', + 'uuid:' + config["main"]["uuid"] + '::upnp:rootdevice', + 'upnp:rootdevice', + 'http://' + config["web"]["plex_accessible_ip"] + ':' + + str(config["web"]["web_admin_port"]) + '/device.xml') + + ssdp.run(config["web"]["bind_ip"]) + + +class SSDPServer: + """A class implementing a SSDP server. The notify_received and + searchReceived methods are called when the appropriate type of + datagram is received by the server.""" + known = {} + + def __init__(self, _config): + self.config = _config + self.sock = None + utils.logging_setup(self.config['paths']) + self.logger = logging.getLogger(__name__) + + def run(self, _bind_ip=''): + + if self.config['ssdp']['udp_netmask'] is None: + self.logger.error('Config setting [ssdp][udp_netmask] required. Exiting ssdp service') + return + try: + IPv4Network(self.config['ssdp']['udp_netmask']) + except (ipaddress.AddressValueError, ValueError) as err: + self.logger.error( + 'Illegal value in [ssdp][udp_netmask]. Format must be #.#.#.#/#. Exiting hdhr service. ERROR: {}' + .format(err)) + return + + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if hasattr(socket, "SO_REUSEPORT"): + try: + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except socket.error as le: + # RHEL6 defines SO_REUSEPORT but it doesn't work + if le.errno == ENOPROTOOPT: + pass + else: + raise + + self.sock.bind(('0.0.0.0', SSDP_PORT)) + + # more info about this from here + mreq = struct.pack('4sl', socket.inet_aton(SSDP_ADDR), socket.INADDR_ANY) + self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + self.sock.settimeout(1) + + while True: + try: + data, addr = self.sock.recvfrom(1024) + self.datagram_received(data, addr) + except socket.timeout: + continue + self.shutdown() + + def shutdown(self): + for st in self.known: + if self.known[st]['MANIFESTATION'] == 'local': + self.do_byebye(st) + + def datagram_received(self, data, host_port): + """Handle a received multicast datagram.""" + (host, port) = host_port + + if self.config['ssdp']['udp_netmask'] is None: + is_allowed = True + else: + try: + net = IPv4Network(self.config['ssdp']['udp_netmask']) + except (ipaddress.AddressValueError, ValueError) as err: + self.logger.error( + 'Illegal value in [ssdp][udp_netmask]. ' + 'Format must be #.#.#.#/#. Exiting hdhr service. ERROR: {}'.format(err)) + sys.exit(1) + is_allowed = IPv4Address(host) in net + + if not is_allowed: + return + + self.logger.debug("SSDP:: {}".format(host_port)) + try: + header, payload = data.decode().split('\r\n\r\n')[:2] + except ValueError as err: + self.logger.error(err) + return + + lines = header.split('\r\n') + cmd = lines[0].split(' ') + lines = [x.replace(': ', ':', 1) for x in lines[1:]] + lines = [x for x in lines if len(x) > 0] + + headers = [x.split(':', 1) for x in lines] + headers = dict([(x[0].lower(), x[1]) for x in headers]) + + self.logger.debug('SSDP command %s %s - from %s:%d' % (cmd[0], cmd[1], host, port)) + self.logger.debug('with headers: {}.'.format(headers)) + if cmd[0] == 'M-SEARCH' and cmd[1] == '*': + # SSDP discovery + self.discovery_request(headers, (host, port)) + elif cmd[0] == 'NOTIFY' and cmd[1] == '*': + # SSDP presence + self.logger.debug('NOTIFY *') + else: + self.logger.debug('Unknown SSDP command %s %s' % (cmd[0], cmd[1])) + + def register(self, manifestation, usn, st, location, server=SERVER_ID, + cache_control='max-age=1800', silent=False, host=None): + """Register a service or device that this SSDP server will + respond to.""" + + self.logger.debug('Registering %s (%s)' % (st, location)) + + self.known[usn] = {} + self.known[usn]['Server'] = server + self.known[usn]['ST'] = st + self.known[usn]['Location'] = location + self.known[usn]['Cache-Control'] = cache_control + self.known[usn]['USN'] = usn + self.known[usn]['Ext'] = None + self.known[usn]['Content-Length'] = 0 + + self.known[usn]['MANIFESTATION'] = manifestation + self.known[usn]['SILENT'] = silent + self.known[usn]['HOST'] = host + + if manifestation == 'local' and self.sock: + self.do_notify(usn) + + def unregister(self, usn): + self.logger.debug("Un-registering %s" % usn) + del self.known[usn] + + def is_known(self, usn): + return usn in self.known + + def send_it(self, response, destination, delay, usn): + self.logger.debug('send discovery response delayed by %ds for %s to %r' % (delay, usn, destination)) + try: + self.sock.sendto(response.encode(), destination) + except (AttributeError, socket.error) as msg: + self.logger.error("failure sending out byebye notification: %r" % msg) + + def discovery_request(self, headers, host_port): + """Process a discovery request. The response must be sent to + the address specified by (host, port).""" + + (host, port) = host_port + + self.logger.debug('Discovery request from (%s,%d) for %s' % (host, port, headers['st'])) + + # Do we know about this service? + for i in list(self.known.values()): + if i['MANIFESTATION'] == 'remote': + continue + if headers['st'] == 'ssdp:all' and i['SILENT']: + continue + if i['ST'] == headers['st'] or headers['st'] == 'ssdp:all': + response = ['HTTP/1.1 200 OK'] + + usn = None + for k, v in list(i.items()): + if k == 'USN': + usn = v + if k not in ('MANIFESTATION', 'SILENT', 'HOST'): + if v is None: + response.append('%s:' % k) + else: + response.append('%s: %s' % (k, v)) + + if usn: + response.append('Date: %s' % formatdate(timeval=None, localtime=False, usegmt=True)) + + response.extend(('', '')) + delay = random.randint(0, int(headers['mx'])) + + self.send_it('\r\n'.join(response), (host, port), delay, usn) + + def do_notify(self, usn): + """Do notification""" + + if self.known[usn]['SILENT']: + return + + self.logger.debug('Sending alive notification for %s' % usn) + + resp = [ + 'NOTIFY * HTTP/1.1', + 'HOST: %s:%d' % (SSDP_ADDR, SSDP_PORT), + 'NTS: ssdp:alive', + ] + stcpy = dict(list(self.known[usn].items())) + stcpy['NT'] = stcpy['ST'] + del stcpy['ST'] + del stcpy['MANIFESTATION'] + del stcpy['SILENT'] + del stcpy['HOST'] + del stcpy['last-seen'] + + resp.extend([': '.join(x) for x in list(stcpy.items())]) + resp.extend(('', '')) + + self.logger.debug('do_notify content', resp) + + try: + self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, SSDP_PORT)) + self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, SSDP_PORT)) + except (AttributeError, socket.error) as msg: + self.logger.debug("failure sending out alive notification: %r" % msg) + + def do_byebye(self, usn): + """Do byebye""" + + self.logger.debug('Sending byebye notification for %s' % usn) + + resp = [ + 'NOTIFY * HTTP/1.1', + 'HOST: %s:%d' % (SSDP_ADDR, SSDP_PORT), + 'NTS: ssdp:byebye', + ] + try: + stcpy = dict(list(self.known[usn].items())) + stcpy['NT'] = stcpy['ST'] + del stcpy['ST'] + del stcpy['MANIFESTATION'] + del stcpy['SILENT'] + del stcpy['HOST'] + del stcpy['last-seen'] + resp.extend([': '.join(x) for x in list(stcpy.items())]) + resp.extend(('', '')) + self.logger.debug('do_byebye content', resp) + if self.sock: + try: + self.sock.sendto('\r\n'.join(resp), (SSDP_ADDR, SSDP_PORT)) + except (AttributeError, socket.error) as msg: + self.logger.error("failure sending out byebye notification: %r" % msg) + except KeyError as msg: + self.logger.error("error building byebye notification: %r" % msg) diff --git a/lib/clients/web_admin.py b/lib/clients/web_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..e6a23c93845423665ad5f4cde3fca047ec3e54c0 --- /dev/null +++ b/lib/clients/web_admin.py @@ -0,0 +1,238 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import errno +import os +import pathlib +import re +import time +from threading import Thread +from http.server import HTTPServer + +import lib.common.utils as utils +from lib.common.decorators import getrequest +from lib.common.decorators import postrequest +from lib.common.decorators import filerequest +from lib.web.pages.templates import web_templates +from .web_handler import WebHTTPHandler + + +@filerequest.route('/html/', '/images/', '/modules/') +def lib_web_htdocs(_webserver): + valid_check = re.match(r'^(/([A-Za-z0-9._\-]+)/[A-Za-z0-9._\-/]+)[?%&A-Za-z0-9._\-/=]*$', _webserver.path) + if not valid_check: + return False + file_path = valid_check.group(1) + htdocs_path = _webserver.config['paths']['www_pkg'] + path_list = file_path.split('/') + fullfile_path = htdocs_path + '.'.join(path_list[:-1]) + _webserver.do_file_response(200, fullfile_path, path_list[-1]) + return True + + +@filerequest.route('/temp/') +def data_web(_webserver): + valid_check = re.match(r'^(/([A-Za-z0-9._\-]+)/[A-Za-z0-9._\-/]+)[?%&A-Za-z0-9._\-/=]*$', _webserver.path) + if not valid_check: + return False + url_path = valid_check.group(1) + + temp_path = pathlib.Path( + _webserver.config['paths']['data_dir'], 'web') + if not temp_path.exists(): + return False + path_list = url_path.split('/') + file_path = temp_path.joinpath(*path_list[:]) + _webserver.do_file_response(200, None, file_path) + + +@getrequest.route('/tunerstatus') +def tunerstatus(_webserver): + _webserver.send_response(302) + _webserver.send_header('Location', '{}{}{}'.format('http://', _webserver.stream_url, '/tunerstatus')) + _webserver.end_headers() + + +class WebAdminHttpHandler(WebHTTPHandler): + # class variables + hdhr_station_scan = -1 + + def __init__(self, *args): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + self.script_dir = pathlib.Path(os.path.dirname(os.path.abspath(__file__))) + self.stream_url = \ + self.config['web']['plex_accessible_ip'] + ':' + \ + str(self.config['web']['plex_accessible_port']) + self.web_admin_url = \ + self.config['web']['plex_accessible_ip'] + \ + ':' + str(self.config['web']['web_admin_port']) + self.content_path = None + self.query_data = None + + try: + super().__init__(*args) + except ConnectionResetError as ex: + self.logger.notice('ConnectionResetError occurred on incoming URL request, will try again {}'.format(str(ex))) + time.sleep(1) + super().__init__(*args) + except ValueError as ex: + self.logger.warning('ValueError occurred, Possible Bad stream recieved. {}'.format(str(ex))) + + def do_GET(self): + try: + valid_check = re.match(r'^(/([A-Za-z0-9._\-]+)/[A-Za-z0-9._\-/]+)[?%&A-Za-z0-9._\-/=]*$', self.path) + self.content_path, self.query_data = self.get_query_data() + self.plugins.config_obj.refresh_config_data() + utils.start_mem_trace(self.config) + self.config = self.plugins.config_obj.data + if filerequest.call_url(self, self.content_path): + pass + elif getrequest.call_url(self, self.content_path): + pass + else: + self.logger.notice('UNKNOWN HTTP Request {}'.format(self.content_path)) + self.do_mime_response(501, 'text/html', + web_templates['htmlError'].format('501 - Not Implemented')) + snapshot = utils.end_mem_trace(self.config) + utils.display_top(self.config, snapshot) + + return + except MemoryError as ex: + self.logger.error('UNKNOWN MEMORY EXCEPTION: {}'.format(ex)) + self.do_mime_response(501, 'text/html', + web_templates['htmlError'].format('501 - {}'.format(ex))) + snapshot = utils.end_mem_trace(self.config) + utils.display_top(self.config, snapshot) + except IOError as ex: + if ex.errno in [errno.EPIPE, errno.ECONNABORTED, errno.ECONNRESET, errno.ECONNREFUSED]: + self.logger.info('Connection dropped by end device {}'.format(ex)) + else: + self.logger.exception('{}{}'.format( + 'UNEXPECTED IOERROR EXCEPTION=', ex)) + snapshot = utils.end_mem_trace(self.config) + utils.display_top(self.config, snapshot) + except Exception as ex: + self.logger.exception('{}{}'.format( + 'UNEXPECTED EXCEPTION on GET=', ex)) + self.do_mime_response(501, 'text/html', + web_templates['htmlError'].format('501 - Server Error')) + snapshot = utils.end_mem_trace(self.config) + utils.display_top(self.config, snapshot) + + def do_POST(self): + try: + self.content_path = self.path + # get POST data + self.content_path, self.query_data = self.get_query_data() + self.logger.debug('Receiving POST form {}'.format(self.content_path)) + self.plugins.config_obj.refresh_config_data() + self.config = self.plugins.config_obj.data + if postrequest.call_url(self, self.content_path): + pass + else: + self.logger.notice('UNKNOWN HTTP POST Request {}'.format(self.content_path)) + self.do_mime_response(501, 'text/html', web_templates['htmlError'].format('501 - Not Implemented')) + except Exception as ex: + self.logger.exception('{}{}'.format( + 'UNEXPECTED EXCEPTION on POST=', ex)) + self.do_mime_response(501, 'text/html', + web_templates['htmlError'].format('501 - Server Error')) + + @classmethod + def get_ns_inst_path(cls, _query_data): + if _query_data['name']: + path = '/' + _query_data['name'] + else: + path = '' + if _query_data['instance']: + path += '/' + _query_data['instance'] + return path + + def put_hdhr_queue(self, _namespace, _index, _channel, _status): + if not self.config['hdhomerun']['disable_hdhr']: + WebAdminHttpHandler.hdhr_queue.put( + {'namespace': _namespace, 'tuner': _index, 'channel': _channel, 'status': _status}) + + def update_scan_status(self, _namespace, _new_status): + if _new_status == 'Scan': + old_status = 'Idle' + else: + old_status = 'Scan' + + if _namespace is None: + for namespace, status_list in WebAdminHttpHandler.rmg_station_scans.items(): + for i, status in enumerate(status_list): + if status == old_status: + WebAdminHttpHandler.rmg_station_scans[namespace][i] = _new_status + self.put_hdhr_queue(namespace, i, None, _new_status) + else: + status_list = WebAdminHttpHandler.rmg_station_scans[_namespace] + for i, status in enumerate(status_list): + if status == old_status: + WebAdminHttpHandler.rmg_station_scans[_namespace][i] = _new_status + self.put_hdhr_queue(_namespace, i, None, _new_status) + + @property + def scan_state(self): + return WebAdminHttpHandler.hdhr_station_scan + + @scan_state.setter + def scan_state(self, new_value): + WebAdminHttpHandler.hdhr_station_scan = new_value + + @classmethod + def init_class_var_sub(cls, _plugins, _hdhr_queue, _terminate_queue, _sched_queue): + super(WebAdminHttpHandler, cls).init_class_var(_plugins, _hdhr_queue, _terminate_queue) + WebHTTPHandler.sched_queue = _sched_queue + getrequest.log_urls() + postrequest.log_urls() + filerequest.log_urls() + + +class WebAdminHttpServer(Thread): + + def __init__(self, server_socket, _plugins): + Thread.__init__(self) + self.bind_ip = _plugins.config_obj.data['web']['bind_ip'] + self.bind_port = _plugins.config_obj.data['web']['web_admin_port'] + self.socket = server_socket + self.server_close = None + self.start() + + def run(self): + HttpHandlerClass = FactoryWebAdminHttpHandler() + httpd = HTTPServer((self.bind_ip, self.bind_port), HttpHandlerClass, bind_and_activate=False) + httpd.socket = self.socket + httpd.server_bind = self.server_close = lambda self: None + httpd.server_activate() + httpd.serve_forever() + + +def FactoryWebAdminHttpHandler(): + class CustomWebAdminHttpHandler(WebAdminHttpHandler): + def __init__(self, *args, **kwargs): + super(CustomWebAdminHttpHandler, self).__init__(*args, **kwargs) + + return CustomWebAdminHttpHandler + + +def start(_plugins, _hdhr_queue, _terminate_queue, _sched_queue): + WebAdminHttpHandler.start_httpserver( + _plugins, _hdhr_queue, _terminate_queue, + _plugins.config_obj.data['web']['web_admin_port'], + WebAdminHttpServer, _sched_queue) diff --git a/lib/clients/web_handler.py b/lib/clients/web_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..5370605f9cbbf4596a3b8f477e8aa3c7dc5cdc52 --- /dev/null +++ b/lib/clients/web_handler.py @@ -0,0 +1,250 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import importlib +import importlib.resources +import logging +import mimetypes +import pathlib +import platform +import re +import socket +import time +import urllib +import urllib.parse +from http.server import BaseHTTPRequestHandler + +import lib.common.utils as utils +from lib.web.pages.templates import web_templates +from lib.config.config_defn import ConfigDefn +from lib.db.db_plugins import DBPlugins +from lib.db.db_channels import DBChannels +from lib.common.pickling import Pickling +from lib.plugins.plugin_handler import PluginHandler + + +class WebHTTPHandler(BaseHTTPRequestHandler): + + plugins = None + hdhr_queue = None + terminate_queue = None + sched_queue = None + config = None + logger = None + channels_db = None + rmg_station_scans = {} + namespace_list = None + total_instances = 0 + + def log_message(self, _format, *args): + try: + if int(args[1]) > 399: + self.logger.warning('[%s] %s' % (self.address_string(), _format % args)) + else: + self.logger.debug('[%s] %s' % (self.address_string(), _format % args)) + except (IndexError, ValueError): + self.logger.error('[%s] %s' % (self.address_string(), _format % args)) + + def get_query_data(self): + content_path = self.path + query_data = {} + if self.headers.get('Content-Length') is not None \ + and self.headers.get('Content-Length') != '0': + post_data = self.rfile.read(int(self.headers.get('Content-Length'))).decode('utf-8') + # if an input is empty, then it will remove it from the list when the dict is gen + query_data = urllib.parse.parse_qs(post_data, keep_blank_values=True) + for key, value in query_data.items(): + if value[0] == '': + query_data[key] = [None] + if self.path.find('?') != -1: + content_path = self.path[0:self.path.find('?')] + get_data = self.path[(self.path.find('?') + 1):] + get_data_elements = get_data.split('&') + for get_data_item in get_data_elements: + get_data_item_split = get_data_item.split('=') + if len(get_data_item_split) > 1: + query_data[get_data_item_split[0]] = get_data_item_split[1] + if 'name' not in query_data: + query_data['name'] = None + if 'instance' not in query_data: + query_data['instance'] = None + if query_data['instance'] or query_data['name']: + return content_path, query_data + path_list = content_path.split('/') + if len(path_list) > 2: + instance = None + for ns in WebHTTPHandler.namespace_list: + if path_list[1].lower() == ns.lower(): + namespace = ns + del path_list[1] + instance_list = WebHTTPHandler.namespace_list[namespace] + if len(path_list) > 2: + for inst in instance_list: + if inst.lower() == path_list[1].lower(): + instance = inst + del path_list[1] + query_data['name'] = namespace + query_data['instance'] = instance + content_path = '/'.join(path_list) + break + return content_path, query_data + + def do_file_response(self, _code, _package, _reply_file): + if _reply_file: + try: + if _package: + x = importlib.resources.read_binary(_package, _reply_file) + else: + # add security to prevent hacker paths + search_file = re.compile(r'^[A-Z]?[:]?([\\\/]([A-Za-z0-9_\-]+[\\\/])+[A-Za-z0-9\._\-]+$)') + valid_check = re.match(search_file, str(_reply_file)) + if not valid_check: + self.logger.info('Invalid filepath {}'.format(_reply_file)) + self.do_mime_response(404, 'text/html', web_templates['htmlError'].format('404 - Invalid File Path')) + return + x_path = pathlib.Path(str(_reply_file)) + with open(x_path, 'br') as reader: + x = reader.read() + mime_lookup = mimetypes.guess_type(_reply_file) + self.send_response(_code) + self.send_header('Content-type', mime_lookup[0]) + self.end_headers() + self.do_write(x) + except IsADirectoryError as e: + self.logger.info('IsADirectoryError:{}'.format(e)) + self.do_mime_response(401, 'text/html', web_templates['htmlError'].format('401 - Unauthorized')) + except FileNotFoundError as e: + self.logger.info('FileNotFoundError:{}'.format(e)) + self.do_mime_response(404, 'text/html', web_templates['htmlError'].format('404 - File Not Found')) + except NotADirectoryError as e: + self.logger.info('NotADirectoryError:{}'.format(e)) + self.do_mime_response(404, 'text/html', web_templates['htmlError'].format('404 - Folder Not Found')) + except ConnectionAbortedError as e: + self.logger.info('ConnectionAbortedError:{}'.format(e)) + except ModuleNotFoundError as e: + self.logger.info('ModuleNotFoundError:{}'.format(e)) + self.do_mime_response(404, 'text/html', web_templates['htmlError'].format('404 - Module Not Found')) + + + + + def do_response(self, _code, _mime, _reply_str=None): + try: + self.send_response(_code) + self.send_header('Content-type', _mime) + self.end_headers() + except BrokenPipeError as ex: + self.logger.notice('BrokenPipeError on do_response(), ignoring {}'.format(str(ex))) + pass + if _reply_str: + self.do_write(_reply_str.encode('utf-8')) + + def do_mime_response(self, _code, _mime, _reply_str=None): + self.do_dict_response({ + 'code': _code, 'headers': {'Content-type': _mime}, + 'text': _reply_str + }) + + def do_dict_response(self, rsp_dict): + """ + { 'code': '[code]', 'headers': { '[name]': '[value]', ... }, 'text': b'...' } + """ + self.send_response(rsp_dict['code']) + for header, value in rsp_dict['headers'].items(): + self.send_header(header, value) + self.end_headers() + if rsp_dict['text']: + self.do_write(rsp_dict['text'].encode('utf-8')) + + def do_write(self, _data): + try: + self.wfile.write(_data) + except BrokenPipeError as ex: + self.logger.debug('Client dropped connection while writing, ignoring. {}'.format(ex)) + + @classmethod + def init_class_var_sub(cls, _plugins, _hdhr_queue, _terminate_queue, _sched_queue): + """ + Interface class + """ + pass + + @classmethod + def init_class_var(cls, _plugins, _hdhr_queue, _terminate_queue): + WebHTTPHandler.logger = logging.getLogger(__name__) + WebHTTPHandler.config = _plugins.config_obj.data + + if platform.system() in ['Windows']: + unpickle_it = Pickling(WebHTTPHandler.config) + _plugins = unpickle_it.from_pickle(_plugins.__class__.__name__) + PluginHandler.cls_plugins = _plugins.plugins + + WebHTTPHandler.plugins = _plugins + WebHTTPHandler.hdhr_queue = _hdhr_queue + WebHTTPHandler.terminate_queue = _terminate_queue + if not cls.plugins.config_obj.defn_json: + cls.plugins.config_obj.defn_json = ConfigDefn(_config=_plugins.config_obj.data) + + plugins_db = DBPlugins(_plugins.config_obj.data) + WebHTTPHandler.namespace_list = plugins_db.get_instances() + WebHTTPHandler.channels_db = DBChannels(_plugins.config_obj.data) + tmp_rmg_scans = {} + for plugin_name in _plugins.plugins.keys(): + if plugin_name: + if _plugins.config_obj.data.get(plugin_name.lower()): + if 'player-tuner_count' in _plugins.config_obj.data[plugin_name.lower()]: + tmp_rmg_scans[plugin_name] = [] + for x in range(int(_plugins.config_obj.data[plugin_name.lower()]['player-tuner_count'])): + tmp_rmg_scans[plugin_name].append('Idle') + WebHTTPHandler.rmg_station_scans = tmp_rmg_scans + if WebHTTPHandler.total_instances == 0: + WebHTTPHandler.total_instances = _plugins.config_obj.data['web']['concurrent_listeners'] + + + @classmethod + def start_httpserver(cls, _plugins, _hdhr_queue, _terminate_queue, _port, _http_server_class, _sched_queue=None): + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + i = 3 + while True: + try: + server_socket.bind((_plugins.config_obj.data['web']['bind_ip'], _port)) + break + except OSError: + time.sleep(3) + i -= 1 + if i < 1: + raise + + server_socket.listen(int(_plugins.config_obj.data['web']['concurrent_listeners'])) + utils.logging_setup(_plugins.config_obj.data) + logger = logging.getLogger(__name__) + cls.init_class_var_sub(_plugins, _hdhr_queue, _terminate_queue, _sched_queue) + if cls.total_instances == 0: + _plugins.config_obj.data['web']['concurrent_listeners'] + logger.info( + '{} Now listening for requests. Number of listeners={}' + .format(cls.__name__, cls.total_instances)) + for i in range(cls.total_instances): + _http_server_class(server_socket, _plugins) + try: + while True: + time.sleep(3600) + except KeyboardInterrupt: + pass diff --git a/lib/clients/web_tuner.py b/lib/clients/web_tuner.py new file mode 100644 index 0000000000000000000000000000000000000000..031c67ae6dc31506cb9259fd391bb9ae203bcfc0 --- /dev/null +++ b/lib/clients/web_tuner.py @@ -0,0 +1,361 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import os +import json +import logging +import os +import pathlib +import signal +import threading +import time +import urllib +from threading import Thread +from logging import config +from http.server import HTTPServer +from urllib.parse import urlparse + +from lib.common import utils +from lib.common.decorators import gettunerrequest +from lib.web.pages.templates import web_templates +from lib.db.db_config_defn import DBConfigDefn +from lib.streams.m3u8_redirect import M3U8Redirect +from lib.streams.internal_proxy import InternalProxy +from lib.streams.ffmpeg_proxy import FFMpegProxy +from lib.streams.streamlink_proxy import StreamlinkProxy +from lib.streams.thread_queue import ThreadQueue +from .web_handler import WebHTTPHandler + + +@gettunerrequest.route('/tunerstatus') +def tunerstatus(_webserver): + _webserver.do_mime_response(200, 'application/json', json.dumps(WebHTTPHandler.rmg_station_scans, cls=ObjectJsonEncoder)) + + +@gettunerrequest.route('RE:/watch/.+') +def watch(_webserver): + sid = _webserver.content_path.replace('/watch/', '') + _webserver.do_tuning(sid, _webserver.query_data['name'], _webserver.query_data['instance']) + + +@gettunerrequest.route('/logreset') +def logreset(_webserver): + logging.config.fileConfig(fname=_webserver.config['paths']['config_file'], + disable_existing_loggers=False) + _webserver.do_mime_response(200, 'text/html') + + +@gettunerrequest.route('RE:/auto/v.+') +def autov(_webserver): + channel = _webserver.content_path.replace('/auto/v', '') + station_list = TunerHttpHandler.channels_db.get_channels( + _webserver.query_data['name'], _webserver.query_data['instance']) + + # check channel number with adjustments + for station in station_list.keys(): + updated_chnum = utils.wrap_chnum( + str(station_list[station][0]['display_number']), station_list[station][0]['namespace'], + station_list[station][0]['instance'], _webserver.config) + if updated_chnum == channel: + _webserver.do_tuning(station, _webserver.query_data['name'], + _webserver.query_data['instance']) + return + + _webserver.do_mime_response(503, 'text/html', web_templates['htmlError'].format('503 - Unknown channel')) + + +class ObjectJsonEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, ThreadQueue): + return str(obj) + else: + return json.JSONEncoder.default(self.obj) + + +class TunerHttpHandler(WebHTTPHandler): + + def __init__(self, *args): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + self.script_dir = pathlib.Path(os.path.dirname(os.path.abspath(__file__))) + self.ffmpeg_proc = None # process for running ffmpeg + self.block_moving_avg = 0 + self.last_refresh = None + self.block_prev_pts = 0 + self.block_prev_time = None + self.buffer_prev_time = None + self.block_max_pts = 0 + self.small_pkt_streaming = False + self.real_namespace = None + self.real_instance = None + self.content_path = None + self.query_data = None + self.m3u8_redirect = M3U8Redirect(TunerHttpHandler.plugins, TunerHttpHandler.hdhr_queue) + self.internal_proxy = InternalProxy(TunerHttpHandler.plugins, TunerHttpHandler.hdhr_queue) + self.ffmpeg_proxy = FFMpegProxy(TunerHttpHandler.plugins, TunerHttpHandler.hdhr_queue) + self.streamlink_proxy = StreamlinkProxy(TunerHttpHandler.plugins, TunerHttpHandler.hdhr_queue) + self.db_configdefn = DBConfigDefn(self.config) + try: + super().__init__(*args) + except ConnectionResetError as ex: + self.logger.warning( + 'ConnectionResetError occurred, will try again {}' + .format(ex)) + time.sleep(1) + super().__init__(*args) + except ValueError as ex: + self.logger.warning( + 'ValueError occurred, Bad stream recieved. {}' + .format(ex)) + raise + + def do_GET(self): + try: + self.content_path, self.query_data = self.get_query_data() + if gettunerrequest.call_url(self, self.content_path): + pass + else: + self.logger.warning('Unknown request to {}'.format(self.content_path)) + self.do_mime_response(501, 'text/html', web_templates['htmlError'].format('501 - Not Implemented')) + except Exception as ex: + self.logger.exception('{}{}'.format( + 'UNEXPECTED EXCEPTION on GET=', ex)) + + def do_POST(self): + try: + self.content_path = self.path + self.query_data = {} + # get POST data + if self.headers.get('Content-Length') != '0': + post_data = self.rfile.read(int(self.headers.get('Content-Length'))).decode('utf-8') + # if an input is empty, then it will remove it from the list when the dict is gen + self.query_data = urllib.parse.parse_qs(post_data) + + # get QUERYSTRING + if self.path.find('?') != -1: + get_data = self.path[(self.path.find('?') + 1):] + get_data_elements = get_data.split('&') + for get_data_item in get_data_elements: + get_data_item_split = get_data_item.split('=') + if len(get_data_item_split) > 1: + self.query_data[get_data_item_split[0]] = get_data_item_split[1] + + self.do_mime_response(501, 'text/html', web_templates['htmlError'].format('501 - Badly Formatted Message')) + except Exception as ex: + self.logger.exception('{}{}'.format( + 'UNEXPECTED EXCEPTION on POST=', ex)) + + def do_tuning(self, sid, _namespace, _instance): + # refresh the config data in case it changed in the web_admin process + self.plugins.config_obj.refresh_config_data() + self.config = self.db_configdefn.get_config() + self.plugins.config_obj.data = self.config + # try: + station_list = TunerHttpHandler.channels_db.get_channels(_namespace, _instance) + try: + self.real_namespace, self.real_instance, station_data = self.get_ns_inst_station(station_list[sid]) + if not self.config[self.real_namespace.lower()]['enabled']: + self.logger.warning( + 'Plugin is not enabled, ignoring request: {} sid:{}' + .format(self.real_namespace, sid)) + self.do_mime_response(503, 'text/html', web_templates['htmlError'].format('503 - Plugin Disabled')) + return + if not self.plugins.plugins[self.real_namespace].plugin_obj: + self.logger.warning( + 'Plugin not initialized, ignoring request: {}:{} sid:{}' + .format(self.real_namespace, self.real_instance, sid)) + self.do_mime_response(503, 'text/html', + web_templates['htmlError'].format('503 - Plugin Not Initialized')) + return + section = self.plugins.plugins[self.real_namespace].plugin_obj.instances[self.real_instance].config_section + if not self.config[section]['enabled']: + self.logger.warning( + 'Plugin Instance is not enabled, ignoring request: {}:{} sid:{}' + .format(self.real_namespace, self.real_instance, sid)) + self.do_mime_response(503, 'text/html', + web_templates['htmlError'].format('503 - Plugin Instance Disabled')) + return + except (KeyError, TypeError): + self.logger.warning( + 'Unknown Channel ID, not found in database {} {} {}' + .format(_namespace, _instance, sid)) + self.do_mime_response(503, 'text/html', web_templates['htmlError'].format('503 - Unknown channel')) + return + self.logger.notice('{}:{} Tuning to channel {}'.format(self.real_namespace, self.real_instance, sid)) + if self.config[section]['player-stream_type'] == 'm3u8redirect': + self.do_dict_response(self.m3u8_redirect.gen_m3u8_response(station_data)) + return + elif self.config[section]['player-stream_type'] == 'internalproxy': + resp = self.internal_proxy.gen_response( + self.real_namespace, self.real_instance, + station_data['display_number'], station_data['json'].get('VOD')) + self.do_dict_response(resp) + if resp['tuner'] < 0: + return + else: + self.internal_proxy.stream(station_data, self.wfile, self.terminate_queue, resp['tuner']) + elif self.config[section]['player-stream_type'] == 'ffmpegproxy': + resp = self.ffmpeg_proxy.gen_response( + self.real_namespace, self.real_instance, + station_data['display_number'], station_data['json'].get('VOD')) + self.do_dict_response(resp) + if resp['tuner'] < 0: + return + else: + self.ffmpeg_proxy.stream(station_data, self.wfile, resp['tuner']) + elif self.config[section]['player-stream_type'] == 'streamlinkproxy': + resp = self.streamlink_proxy.gen_response( + self.real_namespace, self.real_instance, + station_data['display_number'], station_data['json'].get('VOD')) + self.do_dict_response(resp) + if resp['tuner'] < 0: + return + else: + self.streamlink_proxy.stream(station_data, self.wfile, resp['tuner']) + else: + self.do_mime_response(501, 'text/html', web_templates['htmlError'].format('501 - Unknown streamtype')) + self.logger.error('Unknown [player-stream_type] {}' + .format(self.config[section]['player-stream_type'])) + return + station_scans = WebHTTPHandler.rmg_station_scans[self.real_namespace][resp['tuner']] + if station_scans != 'Idle': + if station_scans['mux'] is None or not station_scans['mux'].is_alive(): + self.logger.notice('Provider Connection Closed, ch_id={} {}'.format(sid, threading.get_ident())) + WebHTTPHandler.rmg_station_scans[self.real_namespace][resp['tuner']] = 'Idle' + else: + self.logger.info('1 Client Connection Closed, provider continuing ch_id={} {}'.format(sid, threading.get_ident())) + else: + self.logger.info('2 Client Connection Closed, provider continuing ch_id={} {}'.format(sid, threading.get_ident())) + time.sleep(0.01) + + def get_ns_inst_station(self, _station_data): + lowest_namespace = _station_data[0]['namespace'] + lowest_instance = _station_data[0]['instance'] + station = _station_data[0] + + # do simple checks first. + # is there only one channel? + if len(_station_data) == 1: + return lowest_namespace, \ + lowest_instance, \ + station + + # Is there only one channel instance enabled? + i = 0 + for one_station in _station_data: + if one_station['enabled']: + station = one_station + i += 1 + if i == 1: + return station['namespace'], \ + station['instance'], \ + station + + # round robin capability when instances are tied to a single provider + # must make sure the channel is enabled for both instances + ns = [] + inst = [] + counter = {} + for one_station in _station_data: + ns.append(one_station['namespace']) + inst.append(one_station['instance']) + counter[one_station['instance']] = 0 + for namespace, status_list in WebHTTPHandler.rmg_station_scans.items(): + for status in status_list: + if type(status) is dict: + if status['instance'] not in counter: + counter[status['instance']] = 1 + else: + counter[status['instance']] += 1 + + # pick the instance with the lowest counter + lowest_value = 100 + for instance, value in counter.items(): + if value < lowest_value: + lowest_value = value + lowest_instance = instance + for i in range(len(inst)): + if inst[i] == lowest_instance: + lowest_namespace = ns[i] + break + + # find the station data associated with the pick + for one_station in _station_data: + if one_station['namespace'] == lowest_namespace and \ + one_station['instance'] == lowest_instance: + station = one_station + break + return lowest_namespace, lowest_instance, station + + @classmethod + def init_class_var_sub(cls, _plugins, _hdhr_queue, _terminate_queue, _sched_queue): + WebHTTPHandler.logger = logging.getLogger(__name__) + tuner_count = 0 + for plugin_name in _plugins.plugins.keys(): + if plugin_name: + if _plugins.config_obj.data.get(plugin_name.lower()): + if 'player-tuner_count' in _plugins.config_obj.data[plugin_name.lower()]: + WebHTTPHandler.logger.debug('{} Implementing {} tuners for {}' + .format(cls.__name__, + _plugins.config_obj.data[plugin_name.lower()][ + 'player-tuner_count'], + plugin_name)) + tuner_count += _plugins.config_obj.data[plugin_name.lower()]['player-tuner_count'] + WebHTTPHandler.total_instances = tuner_count + super(TunerHttpHandler, cls).init_class_var(_plugins, _hdhr_queue, _terminate_queue) + + +class TunerHttpServer(Thread): + + def __init__(self, server_socket, _plugins): + Thread.__init__(self) + self.bind_ip = _plugins.config_obj.data['web']['bind_ip'] + self.bind_port = _plugins.config_obj.data['web']['plex_accessible_port'] + self.socket = server_socket + self.server_close = None + self.start() + + def run(self): + HttpHandlerClass = FactoryTunerHttpHandler() + httpd = HTTPServer((self.bind_ip, int(self.bind_port)), HttpHandlerClass, bind_and_activate=False) + httpd.socket = self.socket + httpd.server_bind = self.server_close = lambda self: None + httpd.serve_forever() + + +def FactoryTunerHttpHandler(): + class CustomHttpHandler(TunerHttpHandler): + def __init__(self, *args, **kwargs): + super(CustomHttpHandler, self).__init__(*args, **kwargs) + + return CustomHttpHandler + +def child_exited(sig, frame): + logger = logging.getLogger(__name__) + try: + pid, exitcode = os.wait() + logger.warning('Child process {} exited with code {}'.format(pid, exitcode)) + except ChildProcessError as ex: + logger.warning('Child exit error {}'.format(str(ex))) + +def start(_plugins, _hdhr_queue, _terminate_queue): + # uncomment this to find out about m3u8 subprocess exits + #signal.signal(signal.SIGCHLD, child_exited) + TunerHttpHandler.start_httpserver( + _plugins, _hdhr_queue, _terminate_queue, + _plugins.config_obj.data['web']['plex_accessible_port'], + TunerHttpServer) diff --git a/lib/common/algorithms.py b/lib/common/algorithms.py new file mode 100644 index 0000000000000000000000000000000000000000..7b2558045086fdd10b3b0a92ab06b056221c0de5 --- /dev/null +++ b/lib/common/algorithms.py @@ -0,0 +1,230 @@ +# pycrc -- parameterisable CRC calculation utility and C source code generator +# +# Copyright (c) 2006-2017 Thomas Pircher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + + +""" +CRC algorithms implemented in Python. +If you want to study the Python implementation of the CRC routines, then this +is a good place to start from. + +The algorithms Bit by Bit, Bit by Bit Fast and Table-Driven are implemented. + +This module can also be used as a library from within Python. + +Examples +======== + +This is an example use of the different algorithms: + + from pycrc.algorithms import Crc + + crc = Crc(width = 16, poly = 0x8005, + reflect_in = True, xor_in = 0x0000, + reflect_out = True, xor_out = 0x0000) + print("{0:#x}".format(crc.bit_by_bit("123456789"))) + print("{0:#x}".format(crc.bit_by_bit_fast("123456789"))) + print("{0:#x}".format(crc.table_driven("123456789"))) +""" + + +class Crc(object): + """ + A base class for CRC routines. + """ + # pylint: disable=too-many-instance-attributes + + def __init__(self, width, poly, reflect_in, xor_in, reflect_out, xor_out, table_idx_width=None, slice_by=1): + """The Crc constructor. + + The parameters are as follows: + width + poly + reflect_in + xor_in + reflect_out + xor_out + """ + # pylint: disable=too-many-arguments + + self.width = width + self.poly = poly + self.reflect_in = reflect_in + self.xor_in = xor_in + self.reflect_out = reflect_out + self.xor_out = xor_out + self.tbl_idx_width = table_idx_width + self.slice_by = slice_by + + self.msb_mask = 0x1 << (self.width - 1) + self.mask = ((self.msb_mask - 1) << 1) | 1 + if self.tbl_idx_width is not None: + self.tbl_width = 1 << self.tbl_idx_width + else: + self.tbl_idx_width = 8 + self.tbl_width = 1 << self.tbl_idx_width + + self.direct_init = self.xor_in + self.nondirect_init = self.__get_nondirect_init(self.xor_in) + if self.width < 8: + self.crc_shift = 8 - self.width + else: + self.crc_shift = 0 + + def __get_nondirect_init(self, init): + """ + return the non-direct init if the direct algorithm has been selected. + """ + crc = init + for dummy_i in range(self.width): + bit = crc & 0x01 + if bit: + crc ^= self.poly + crc >>= 1 + if bit: + crc |= self.msb_mask + return crc & self.mask + + def reflect(self, data, width): + """ + reflect a data word, i.e. reverts the bit order. + """ + # pylint: disable=no-self-use + + res = data & 0x01 + for dummy_i in range(width - 1): + data >>= 1 + res = (res << 1) | (data & 0x01) + return res + + def bit_by_bit(self, in_data): + """ + Classic simple and slow CRC implementation. This function iterates bit + by bit over the augmented input message and returns the calculated CRC + value at the end. + """ + # If the input data is a string, convert to bytes. + if isinstance(in_data, str): + in_data = bytearray(in_data, 'utf-8') + + reg = self.nondirect_init + for octet in in_data: + if self.reflect_in: + octet = self.reflect(octet, 8) + for i in range(8): + topbit = reg & self.msb_mask + reg = ((reg << 1) & self.mask) | ((octet >> (7 - i)) & 0x01) + if topbit: + reg ^= self.poly + + for i in range(self.width): + topbit = reg & self.msb_mask + reg = ((reg << 1) & self.mask) + if topbit: + reg ^= self.poly + + if self.reflect_out: + reg = self.reflect(reg, self.width) + return (reg ^ self.xor_out) & self.mask + + def bit_by_bit_fast(self, in_data): + """ + This is a slightly modified version of the bit-by-bit algorithm: it + does not need to loop over the augmented bits, i.e. the Width 0-bits + wich are appended to the input message in the bit-by-bit algorithm. + """ + # If the input data is a string, convert to bytes. + if isinstance(in_data, str): + in_data = bytearray(in_data, 'utf-8') + + reg = self.direct_init + for octet in in_data: + if self.reflect_in: + octet = self.reflect(octet, 8) + for i in range(8): + topbit = reg & self.msb_mask + if octet & (0x80 >> i): + topbit ^= self.msb_mask + reg <<= 1 + if topbit: + reg ^= self.poly + reg &= self.mask + if self.reflect_out: + reg = self.reflect(reg, self.width) + return reg ^ self.xor_out + + def gen_table(self): + """ + This function generates the CRC table used for the table_driven CRC + algorithm. The Python version cannot handle tables of an index width + other than 8. See the generated C code for tables with different sizes + instead. + """ + table_length = 1 << self.tbl_idx_width + tbl = [[0 for i in range(table_length)] for j in range(self.slice_by)] + for i in range(table_length): + reg = i + if self.reflect_in: + reg = self.reflect(reg, self.tbl_idx_width) + reg = reg << (self.width - self.tbl_idx_width + self.crc_shift) + for dummy_j in range(self.tbl_idx_width): + if reg & (self.msb_mask << self.crc_shift) != 0: + reg = (reg << 1) ^ (self.poly << self.crc_shift) + else: + reg = (reg << 1) + if self.reflect_in: + reg = self.reflect(reg >> self.crc_shift, self.width) << self.crc_shift + tbl[0][i] = (reg >> self.crc_shift) & self.mask + + for j in range(1, self.slice_by): + for i in range(table_length): + tbl[j][i] = (tbl[j - 1][i] >> 8) ^ tbl[0][tbl[j - 1][i] & 0xff] + return tbl + + def table_driven(self, in_data): + """ + The Standard table_driven CRC algorithm. + """ + # pylint: disable = line-too-long + + # If the input data is a string, convert to bytes. + if isinstance(in_data, str): + in_data = bytearray(in_data, 'utf-8') + + tbl = self.gen_table() + + if not self.reflect_in: + reg = self.direct_init << self.crc_shift + for octet in in_data: + tblidx = ((reg >> (self.width - self.tbl_idx_width + self.crc_shift)) ^ octet) & 0xff + reg = ((reg << (self.tbl_idx_width - self.crc_shift)) ^ + (tbl[0][tblidx] << self.crc_shift)) & (self.mask << self.crc_shift) + reg = reg >> self.crc_shift + else: + reg = self.reflect(self.direct_init, self.width) + for octet in in_data: + tblidx = (reg ^ octet) & 0xff + reg = ((reg >> self.tbl_idx_width) ^ tbl[0][tblidx]) & self.mask + reg = self.reflect(reg, self.width) & self.mask + + if self.reflect_out: + reg = self.reflect(reg, self.width) + return reg ^ self.xor_out diff --git a/lib/common/decorators.py b/lib/common/decorators.py new file mode 100644 index 0000000000000000000000000000000000000000..543e20c362bfd8bb05d5b1a781af80fb33a50329 --- /dev/null +++ b/lib/common/decorators.py @@ -0,0 +1,349 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import functools +import http +import http.client +import json +import logging +import os +import re +import requests.exceptions +import sys +import socket +import time +import traceback +import urllib +import urllib.parse +import urllib.error +import urllib3 +from functools import update_wrapper + + +def handle_url_except(f=None, timeout=None): + """ + timeout not currently used + """ + if f is None: + return functools.partial(handle_url_except, timeout=timeout) + + def wrapper_func(self, *args, **kwargs): + ex_save = None + # arg0 = uri, arg1=retries + if len(args) == 0: + arg0 = 'None' + retries = 2 + elif len(args) == 1: + arg0 = args[0] + retries = 2 + else: + arg0 = args[0] + retries = args[1] + i = retries + is_done = 0 + while i > is_done: + i -= 1 + try: + x = f(self, *args, **kwargs) + return x + except UnicodeDecodeError as ex: + ex_save = ex + self.logger.info("UnicodeDecodeError in function {}(), retrying {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0), )) + except ConnectionRefusedError as ex: + ex_save = ex + self.logger.info("ConnectionRefusedError in function {}(), retrying (): {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + except ConnectionResetError as ex: + ex_save = ex + self.logger.info("ConnectionResetError in function {}(), retrying {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + + except requests.exceptions.InvalidURL as ex: + ex_save = ex + self.logger.info("InvalidURL Error in function {}(), retrying {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + + except (socket.timeout, requests.exceptions.ConnectTimeout, requests.exceptions.ReadTimeout) as ex: + ex_save = ex + self.logger.info("Socket Timeout Error in function {}(), retrying {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + + except requests.exceptions.ConnectionError as ex: + ex_save = ex + if hasattr(ex.args[0], 'reason'): + reason = ex.args[0].reason + else: + reason = None + if isinstance(reason, urllib3.exceptions.NewConnectionError): + self.logger.info("ConnectionError:ConnectionRefused in function {}(), retrying (): {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + time.sleep(2) + else: + self.logger.info("ConnectionError in function {}(), retrying (): {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + except http.client.RemoteDisconnected as ex: + ex_save = ex + self.logger.info('Remote Server Disconnect Error in function {}(), retrying {} {} {}' + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + except http.client.InvalidURL as ex: + url_tuple = urllib.parse.urlparse(arg0) + url_list = list(url_tuple) + url_list[2] = urllib.parse.quote(url_list[2]) + url_list[3] = urllib.parse.quote(url_list[3]) + url_list[4] = urllib.parse.quote(url_list[4]) + url_list[5] = urllib.parse.quote(url_list[5]) + new_url = urllib.parse.urlunparse(url_list) + args_list = list(args) + args_list[0] = new_url + args = tuple(args_list) + + ex_save = ex + self.logger.info('InvalidURL Error, encoding and trying again. In function {}() {} {} {}' + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + + except (requests.exceptions.HTTPError, urllib.error.HTTPError) as ex: + ex_save = ex + self.logger.info("HTTPError in function {}(), retrying {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0), )) + except urllib.error.URLError as ex: + ex_save = ex + if isinstance(ex.reason, ConnectionRefusedError): + self.logger.info("URLError:ConnectionRefusedError in function {}(): {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + count = 5 + while count > 0: + try: + x = f(self, *args, **kwargs) + return x + except urllib.error.URLError as ex2: + self.logger.debug("{} URLError:ConnectionRefusedError in function {}(): {} {} {}" + .format(count, f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + count -= 1 + time.sleep(.5) + except Exception as ex3: + break + else: + self.logger.info("URLError in function {}(), retrying (): {} {} {}" + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + except http.client.IncompleteRead as ex: + ex_save = ex + self.logger.info('Partial Data Error from url received in function {}(), retrying. {} {} {}' + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + except ValueError as ex: + ex_save = ex + self.logger.info('ValueError in function {}(), aborting. {} {} {}' + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + except (requests.exceptions.ProxyError, requests.exceptions.SSLError, \ + requests.exceptions.TooManyRedirects, requests.exceptions.InvalidHeader, \ + requests.exceptions.InvalidProxyURL, requests.exceptions.ChunkedEncodingError, \ + requests.exceptions.ContentDecodingError, requests.exceptions.StreamConsumedError, \ + requests.exceptions.RetryError, requests.exceptions.UnrewindableBodyError, \ + requests.exceptions.RequestsWarning, requests.exceptions.FileModeWarning, \ + requests.exceptions.RequestsDependencyWarning) as ex: + ex_save = ex + self.logger.info('General Requests Error in function {}(), retrying. {} {} {}' + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + except (requests.exceptions.MissingSchema, requests.exceptions.InvalidSchema) as ex: + ex_save = ex + self.logger.info('Missing Schema Error in function {}(), retrying. {} {} {}' + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + time.sleep(1.0) + self.logger.notice('Multiple HTTP Errors, unable to get url data, skipping {}() {} {} {}' + .format(f.__qualname__, os.getpid(), str(ex_save), str(arg0))) + + return None + + return update_wrapper(wrapper_func, f) + + +def handle_json_except(f): + def wrapper_func(self, *args, **kwargs): + try: + return f(self, *args, **kwargs) + except (json.JSONDecodeError) as jsonError: + self.logger.error("JSONError in function {}(): {}".format(f.__qualname__, str(jsonError))) + return None + + return update_wrapper(wrapper_func, f) + + +class Backup: + """ + Decorator for collecting and processing export/backup methods + """ + + backup2func = {} + + def __init__(self, *pattern): + self.pattern = pattern + + def __call__(self, call_class_fn): + if call_class_fn is None: + return call_class_fn + else: + for p in self.pattern: + Backup.backup2func[p] = call_class_fn + return call_class_fn + + @classmethod + def log_backups(cls): + logger = logging.getLogger(__name__) + for name in Backup.backup2func.keys(): + logger.debug('Registering BACKUP {}'.format(name)) + + @classmethod + def call_backup(cls, _name, *args, **kwargs): + """ + Based on function, will create class instance and call + the function with no parameters. *args are + passed into the class constructor while **kwargs are + passed into the instance function + """ + if _name in Backup.backup2func: + fn = Backup.backup2func[_name] + module = fn.__module__ + class_fn = fn.__qualname__ + (cls_name, fn_name) = class_fn.split('.') + cls = vars(sys.modules[module])[cls_name] + inst = cls(*args) + inst_fn = getattr(inst, fn_name) + inst_fn(**kwargs) + return True + else: + return False + + +class Restore: + """ + Decorator for collecting and processing import/restore methods + """ + + restore2func = {} + + def __init__(self, *pattern): + self.pattern = pattern + + def __call__(self, call_class_fn): + if call_class_fn is None: + return call_class_fn + else: + for p in self.pattern: + Restore.restore2func[p] = call_class_fn + return call_class_fn + + @classmethod + def log_backups(cls): + logger = logging.getLogger(__name__) + for name in Restore.restore2func.keys(): + logger.debug('Registering RESTORE {}'.format(name)) + + @classmethod + def call_restore(cls, _name, *args, **kwargs): + """ + Based on function, will create class instance and call + the function with no parameters. *args are + passed into the class constructor while **kwargs are + passed into the instance function + """ + if _name in Restore.restore2func: + fn = Restore.restore2func[_name] + module = fn.__module__ + class_fn = fn.__qualname__ + (cls_name, fn_name) = class_fn.split('.') + cls = vars(sys.modules[module])[cls_name] + inst = cls(*args) + inst_fn = getattr(inst, fn_name) + msg = inst_fn(**kwargs) + return msg + else: + return None + + +class Request: + """ + Adds urls to functions for GET and POST methods + """ + + def __init__(self): + self.url2func = {} + self.method = None + + def route(self, *pattern): + def wrap(func): + for p in pattern: + if p.startswith('RE:'): + p = re.compile(p.replace('RE:', '')) + self.url2func[p] = func + return func + + return wrap + + def log_urls(self): + logger = logging.getLogger(__name__) + for name in self.url2func.keys(): + logger.debug('Registering {} URL: {}'.format(self.method, name)) + + def call_url(self, _webserver, _name, *args, **kwargs): + if _name in self.url2func: + self.url2func[_name](_webserver, *args, **kwargs) + return True + else: + for uri in self.url2func.keys(): + if type(uri) is re.Pattern: + if len(uri.findall(_name)) > 0: + self.url2func[uri](_webserver, *args, **kwargs) + return True + return False + + +class GetRequest(Request): + + def __init__(self): + super().__init__() + self.method = 'GET' + + +class PostRequest(Request): + + def __init__(self): + super().__init__() + self.method = 'POST' + + +class FileRequest(Request): + """ + Adds HTDOCS areas to be processed by function + """ + + def __init__(self): + super().__init__() + self.method = 'GET' + + def call_url(self, _webserver, _name, *args, **kwargs): + for key in self.url2func.keys(): + if _name.startswith(key): + self.url2func[key](_webserver, *args, **kwargs) + return True + return False + + +getrequest = GetRequest() +gettunerrequest = GetRequest() +postrequest = PostRequest() +filerequest = FileRequest() diff --git a/lib/common/encryption.py b/lib/common/encryption.py new file mode 100644 index 0000000000000000000000000000000000000000..ff136ba5ff31a313b987b526029e5e29abd0fbbd --- /dev/null +++ b/lib/common/encryption.py @@ -0,0 +1,80 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import platform +import os + +try: + import cryptography + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + from cryptography.fernet import Fernet + + CRYPTO_LOADED = True +except ImportError: + CRYPTO_LOADED = False + +ENCRYPT_STRING = 'ENC::' + +LOGGER = logging.getLogger(__name__) + + +def set_fernet_key(): + opersystem = platform.system() + # is there a key already generated + if opersystem in ['Windows']: + key_file = os.getenv('LOCALAPPDATA') + '/.cabernet/key.txt' + else: # linux + key_file = os.getenv('HOME') + '/.cabernet/key.txt' + try: + with open(key_file, 'rb') as f: + key = f.read() + except FileNotFoundError: + key = Fernet.generate_key() + os.makedirs(os.path.dirname(key_file), exist_ok=True) + with open(key_file, 'wb') as f: + f.write(key) + return key + + +def encrypt(clearstr, encrypt_key): + if clearstr.startswith(ENCRYPT_STRING): + # already encrypted_pwd + return clearstr + else: + f = Fernet(encrypt_key) + token = f.encrypt(clearstr.encode()) + return ENCRYPT_STRING + token.decode() + + +def decrypt(enc_str, encrypt_key): + if enc_str.startswith(ENCRYPT_STRING): + f = Fernet(encrypt_key) + try: + token = f.decrypt(enc_str[len(ENCRYPT_STRING):].encode()) + except cryptography.fernet.InvalidToken: + # occurs when multiple users are running the app. + # need to signal the caller that we have issues + LOGGER.warning("Unable to decrypt string.") + return None + + return token.decode() + else: + return enc_str diff --git a/lib/common/exceptions.py b/lib/common/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..9206fcd03737aa4ca73e1639464b4220d1c885ce --- /dev/null +++ b/lib/common/exceptions.py @@ -0,0 +1,7 @@ + +class CabernetException(Exception): + def __init__(self, value): + self.value = value + + def __str__(self): + return 'CabernetException: %s' % self.value diff --git a/lib/common/filelock.py b/lib/common/filelock.py new file mode 100644 index 0000000000000000000000000000000000000000..6e5424ed6ecf61eccc75a5fd3a9c8340a03a6c4a --- /dev/null +++ b/lib/common/filelock.py @@ -0,0 +1,451 @@ +# This is free and unencumbered software released into the public domain. +# +# Anyone is free to copy, modify, publish, use, compile, sell, or +# distribute this software, either in source code form or as a compiled +# binary, for any purpose, commercial or non-commercial, and by any +# means. +# +# In jurisdictions that recognize copyright laws, the author or authors +# of this software dedicate any and all copyright interest in the +# software to the public domain. We make this dedication for the benefit +# of the public at large and to the detriment of our heirs and +# successors. We intend this dedication to be an overt act of +# relinquishment in perpetuity of all present and future rights to this +# software under copyright law. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# For more information, please refer to + +""" +A platform independent file lock that supports the with-statement. +""" + + +# Modules +# ------------------------------------------------ +import logging +import os +import threading +import time +try: + import warnings +except ImportError: + warnings = None + +try: + import msvcrt +except ImportError: + msvcrt = None + +try: + import fcntl +except ImportError: + fcntl = None + + +# Backward compatibility +# ------------------------------------------------ +try: + TimeoutError +except NameError: + TimeoutError = OSError + + +# Data +# ------------------------------------------------ +__all__ = [ + "Timeout", + "BaseFileLock", + "WindowsFileLock", + "UnixFileLock", + "SoftFileLock", + "FileLock" +] + +__version__ = "3.0.12" + + +_logger = None +def logger(): + """Returns the logger instance used in this module.""" + global _logger + _logger = _logger or logging.getLogger(__name__) + return _logger + + +# Exceptions +# ------------------------------------------------ +class Timeout(TimeoutError): + """ + Raised when the lock could not be acquired in *timeout* + seconds. + """ + + def __init__(self, lock_file): + """ + """ + #: The path of the file lock. + self.lock_file = lock_file + return None + + def __str__(self): + temp = "The file lock '{}' could not be acquired."\ + .format(self.lock_file) + return temp + + +# Classes +# ------------------------------------------------ + +# This is a helper class which is returned by :meth:`BaseFileLock.acquire` +# and wraps the lock to make sure __enter__ is not called twice when entering +# the with statement. +# If we would simply return *self*, the lock would be acquired again +# in the *__enter__* method of the BaseFileLock, but not released again +# automatically. +# +# :seealso: issue #37 (memory leak) +class _Acquire_ReturnProxy(object): + + def __init__(self, lock): + self.lock = lock + return None + + def __enter__(self): + return self.lock + + def __exit__(self, exc_type, exc_value, traceback): + self.lock.release() + return None + + +class BaseFileLock(object): + """ + Implements the base class of a file lock. + """ + + def __init__(self, lock_file, timeout = -1): + """ + """ + # The path to the lock file. + self._lock_file = lock_file + + # The file descriptor for the *_lock_file* as it is returned by the + # os.open() function. + # This file lock is only NOT None, if the object currently holds the + # lock. + self._lock_file_fd = None + + # The default timeout value. + self.timeout = timeout + + # We use this lock primarily for the lock counter. + self._thread_lock = threading.Lock() + + # The lock counter is used for implementing the nested locking + # mechanism. Whenever the lock is acquired, the counter is increased and + # the lock is only released, when this value is 0 again. + self._lock_counter = 0 + return None + + @property + def lock_file(self): + """ + The path to the lock file. + """ + return self._lock_file + + @property + def timeout(self): + """ + You can set a default timeout for the filelock. It will be used as + fallback value in the acquire method, if no timeout value (*None*) is + given. + + If you want to disable the timeout, set it to a negative value. + + A timeout of 0 means, that there is exactly one attempt to acquire the + file lock. + + .. versionadded:: 2.0.0 + """ + return self._timeout + + @timeout.setter + def timeout(self, value): + """ + """ + self._timeout = float(value) + return None + + # Platform dependent locking + # -------------------------------------------- + + def _acquire(self): + """ + Platform dependent. If the file lock could be + acquired, self._lock_file_fd holds the file descriptor + of the lock file. + """ + raise NotImplementedError() + + def _release(self): + """ + Releases the lock and sets self._lock_file_fd to None. + """ + raise NotImplementedError() + + # Platform independent methods + # -------------------------------------------- + + @property + def is_locked(self): + """ + True, if the object holds the file lock. + + .. versionchanged:: 2.0.0 + + This was previously a method and is now a property. + """ + return self._lock_file_fd is not None + + def acquire(self, timeout=None, poll_intervall=0.05): + """ + Acquires the file lock or fails with a :exc:`Timeout` error. + + .. code-block:: python + + # You can use this method in the context manager (recommended) + with lock.acquire(): + pass + + # Or use an equivalent try-finally construct: + lock.acquire() + try: + pass + finally: + lock.release() + + :arg float timeout: + The maximum time waited for the file lock. + If ``timeout < 0``, there is no timeout and this method will + block until the lock could be acquired. + If ``timeout`` is None, the default :attr:`~timeout` is used. + + :arg float poll_intervall: + We check once in *poll_intervall* seconds if we can acquire the + file lock. + + :raises Timeout: + if the lock could not be acquired in *timeout* seconds. + + .. versionchanged:: 2.0.0 + + This method returns now a *proxy* object instead of *self*, + so that it can be used in a with statement without side effects. + """ + # Use the default timeout, if no timeout is provided. + if timeout is None: + timeout = self.timeout + + # Increment the number right at the beginning. + # We can still undo it, if something fails. + with self._thread_lock: + self._lock_counter += 1 + + lock_id = id(self) + lock_filename = self._lock_file + start_time = time.time() + try: + while True: + with self._thread_lock: + if not self.is_locked: + logger().debug('Attempting to acquire lock %s on %s', lock_id, lock_filename) + self._acquire() + + if self.is_locked: + logger().debug('Lock %s acquired on %s', lock_id, lock_filename) + break + elif timeout >= 0 and time.time() - start_time > timeout: + logger().debug('Timeout on acquiring lock %s on %s', lock_id, lock_filename) + raise Timeout(self._lock_file) + else: + logger().debug( + 'Lock %s not acquired on %s, waiting %s seconds ...', + lock_id, lock_filename, poll_intervall + ) + time.sleep(poll_intervall) + except: + # Something did go wrong, so decrement the counter. + with self._thread_lock: + self._lock_counter = max(0, self._lock_counter - 1) + + raise + return _Acquire_ReturnProxy(lock = self) + + def release(self, force = False): + """ + Releases the file lock. + + Please note, that the lock is only completly released, if the lock + counter is 0. + + Also note, that the lock file itself is not automatically deleted. + + :arg bool force: + If true, the lock counter is ignored and the lock is released in + every case. + """ + with self._thread_lock: + + if self.is_locked: + self._lock_counter -= 1 + + if self._lock_counter == 0 or force: + lock_id = id(self) + lock_filename = self._lock_file + + logger().debug('Attempting to release lock %s on %s', lock_id, lock_filename) + self._release() + self._lock_counter = 0 + logger().debug('Lock %s released on %s', lock_id, lock_filename) + + return None + + def __enter__(self): + self.acquire() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.release() + return None + + def __del__(self): + self.release(force = True) + return None + + +# Windows locking mechanism +# ~~~~~~~~~~~~~~~~~~~~~~~~~ + +class WindowsFileLock(BaseFileLock): + """ + Uses the :func:`msvcrt.locking` function to hard lock the lock file on + windows systems. + """ + + def _acquire(self): + open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC + + try: + fd = os.open(self._lock_file, open_mode) + except OSError: + pass + else: + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except (IOError, OSError): + os.close(fd) + else: + self._lock_file_fd = fd + return None + + def _release(self): + fd = self._lock_file_fd + self._lock_file_fd = None + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + os.close(fd) + + try: + os.remove(self._lock_file) + # Probably another instance of the application + # that acquired the file lock. + except OSError: + pass + return None + +# Unix locking mechanism +# ~~~~~~~~~~~~~~~~~~~~~~ + +class UnixFileLock(BaseFileLock): + """ + Uses the :func:`fcntl.flock` to hard lock the lock file on unix systems. + """ + + def _acquire(self): + open_mode = os.O_RDWR | os.O_CREAT | os.O_TRUNC + fd = os.open(self._lock_file, open_mode) + + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (IOError, OSError): + os.close(fd) + else: + self._lock_file_fd = fd + return None + + def _release(self): + # Do not remove the lockfile: + # + # https://github.com/benediktschmitt/py-filelock/issues/31 + # https://stackoverflow.com/questions/17708885/flock-removing-locked-file-without-race-condition + fd = self._lock_file_fd + self._lock_file_fd = None + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + return None + +# Soft lock +# ~~~~~~~~~ + +class SoftFileLock(BaseFileLock): + """ + Simply watches the existence of the lock file. + """ + + def _acquire(self): + open_mode = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC + try: + fd = os.open(self._lock_file, open_mode) + except (IOError, OSError): + pass + else: + self._lock_file_fd = fd + return None + + def _release(self): + os.close(self._lock_file_fd) + self._lock_file_fd = None + + try: + os.remove(self._lock_file) + # The file is already deleted and that's what we want. + except OSError: + pass + return None + + +# Platform filelock +# ~~~~~~~~~~~~~~~~~ + +#: Alias for the lock, which should be used for the current platform. On +#: Windows, this is an alias for :class:`WindowsFileLock`, on Unix for +#: :class:`UnixFileLock` and otherwise for :class:`SoftFileLock`. +FileLock = None + +if msvcrt: + FileLock = WindowsFileLock +elif fcntl: + FileLock = UnixFileLock +else: + FileLock = SoftFileLock + + if warnings is not None: + warnings.warn("only soft file lock is available") \ No newline at end of file diff --git a/lib/common/log_handlers.py b/lib/common/log_handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..57543d6a811ec950bf5f1a2b78297f1c8f822480 --- /dev/null +++ b/lib/common/log_handlers.py @@ -0,0 +1,39 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +from logging.handlers import RotatingFileHandler + + +class MPRotatingFileHandler(RotatingFileHandler): + """ + Supports multiprocessing logging. Main issue is when the + file rotates, the other process need to be notified and + move their file pointers to the new file and not + create yet another rotation. + """ + + def shouldRollover(self, record): + """ + Override method + """ + if super().shouldRollover(record): + if self.stream is not None: + self.stream.close() + self.stream = self._open() + return super().shouldRollover(record) + return 0 diff --git a/lib/common/models.py b/lib/common/models.py new file mode 100644 index 0000000000000000000000000000000000000000..c887fd178b09c784a4bab00e5e8deb0e3ac63abe --- /dev/null +++ b/lib/common/models.py @@ -0,0 +1,331 @@ +# pycrc -- parameterisable CRC calculation utility and C source code generator +# +# Copyright (c) 2006-2017 Thomas Pircher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + + +""" +Collection of CRC models. This module contains the CRC models known to pycrc. + +To print the parameters of a particular model: + + import pycrc.models as cm + + models = cm.CrcModels() + print(", ".join(models.names())) + m = models.get_params("crc-32") + if m != None: + print("Width: {width:d}".format(**m)) + print("Poly: {poly:#x}".format(**m)) + print("ReflectIn: {reflect_in}".format(**m)) + print("XorIn: {xor_in:#x}".format(**m)) + print("ReflectOut: {reflect_out}".format(**m)) + print("XorOut: {xor_out:#x}".format(**m)) + print("Check: {check:#x}".format(**m)) + else: + print("model not found.") +""" + + +class CrcModels(object): + """ + CRC Models. + + All models are defined as constant class variables. + """ + + models = [] + + models.append({ + 'name': 'crc-5', + 'width': 5, + 'poly': 0x05, + 'reflect_in': True, + 'xor_in': 0x1f, + 'reflect_out': True, + 'xor_out': 0x1f, + 'check': 0x19, + }) + models.append({ + 'name': 'crc-8', + 'width': 8, + 'poly': 0x07, + 'reflect_in': False, + 'xor_in': 0x0, + 'reflect_out': False, + 'xor_out': 0x0, + 'check': 0xf4, + }) + models.append({ + 'name': 'dallas-1-wire', + 'width': 8, + 'poly': 0x31, + 'reflect_in': True, + 'xor_in': 0x0, + 'reflect_out': True, + 'xor_out': 0x0, + 'check': 0xa1, + }) + models.append({ + 'name': 'crc-12-3gpp', + 'width': 12, + 'poly': 0x80f, + 'reflect_in': False, + 'xor_in': 0x0, + 'reflect_out': True, + 'xor_out': 0x0, + 'check': 0xdaf, + }) + models.append({ + 'name': 'crc-15', + 'width': 15, + 'poly': 0x4599, + 'reflect_in': False, + 'xor_in': 0x0, + 'reflect_out': False, + 'xor_out': 0x0, + 'check': 0x59e, + }) + models.append({ + 'name': 'crc-16', + 'width': 16, + 'poly': 0x8005, + 'reflect_in': True, + 'xor_in': 0x0, + 'reflect_out': True, + 'xor_out': 0x0, + 'check': 0xbb3d, + }) + models.append({ + 'name': 'crc-16-usb', + 'width': 16, + 'poly': 0x8005, + 'reflect_in': True, + 'xor_in': 0xffff, + 'reflect_out': True, + 'xor_out': 0xffff, + 'check': 0xb4c8, + }) + models.append({ + 'name': 'crc-16-modbus', + 'width': 16, + 'poly': 0x8005, + 'reflect_in': True, + 'xor_in': 0xffff, + 'reflect_out': True, + 'xor_out': 0x0, + 'check': 0x4b37, + }) + models.append({ + 'name': 'crc-16-genibus', + 'width': 16, + 'poly': 0x1021, + 'reflect_in': False, + 'xor_in': 0xffff, + 'reflect_out': False, + 'xor_out': 0xffff, + 'check': 0xd64e, + }) + models.append({ + 'name': 'crc-16-ccitt', + 'width': 16, + 'poly': 0x1021, + 'reflect_in': False, + 'xor_in': 0x1d0f, + 'reflect_out': False, + 'xor_out': 0x0, + 'check': 0xe5cc, + }) + models.append({ + 'name': 'r-crc-16', + 'width': 16, + 'poly': 0x0589, + 'reflect_in': False, + 'xor_in': 0x0, + 'reflect_out': False, + 'xor_out': 0x0001, + 'check': 0x007e, + }) + models.append({ + 'name': 'kermit', + 'width': 16, + 'poly': 0x1021, + 'reflect_in': True, + 'xor_in': 0x0, + 'reflect_out': True, + 'xor_out': 0x0, + 'check': 0x2189, + }) + models.append({ + 'name': 'x-25', + 'width': 16, + 'poly': 0x1021, + 'reflect_in': True, + 'xor_in': 0xffff, + 'reflect_out': True, + 'xor_out': 0xffff, + 'check': 0x906e, + }) + models.append({ + 'name': 'xmodem', + 'width': 16, + 'poly': 0x1021, + 'reflect_in': False, + 'xor_in': 0x0, + 'reflect_out': False, + 'xor_out': 0x0, + 'check': 0x31c3, + }) + models.append({ + 'name': 'zmodem', + 'width': 16, + 'poly': 0x1021, + 'reflect_in': False, + 'xor_in': 0x0, + 'reflect_out': False, + 'xor_out': 0x0, + 'check': 0x31c3, + }) + models.append({ + 'name': 'crc-24', + 'width': 24, + 'poly': 0x864cfb, + 'reflect_in': False, + 'xor_in': 0xb704ce, + 'reflect_out': False, + 'xor_out': 0x0, + 'check': 0x21cf02, + }) + models.append({ + 'name': 'crc-32', + 'width': 32, + 'poly': 0x4c11db7, + 'reflect_in': True, + 'xor_in': 0xffffffff, + 'reflect_out': True, + 'xor_out': 0xffffffff, + 'check': 0xcbf43926, + }) + models.append({ + 'name': 'crc-32c', + 'width': 32, + 'poly': 0x1edc6f41, + 'reflect_in': True, + 'xor_in': 0xffffffff, + 'reflect_out': True, + 'xor_out': 0xffffffff, + 'check': 0xe3069283, + }) + models.append({ + 'name': 'crc-32-mpeg', + 'width': 32, + 'poly': 0x4c11db7, + 'reflect_in': False, + 'xor_in': 0xffffffff, + 'reflect_out': False, + 'xor_out': 0x0, + 'check': 0x0376e6e7, + }) + models.append({ + 'name': 'crc-32-bzip2', + 'width': 32, + 'poly': 0x04c11db7, + 'reflect_in': False, + 'xor_in': 0xffffffff, + 'reflect_out': False, + 'xor_out': 0xffffffff, + 'check': 0xfc891918, + }) + models.append({ + 'name': 'posix', + 'width': 32, + 'poly': 0x4c11db7, + 'reflect_in': False, + 'xor_in': 0x0, + 'reflect_out': False, + 'xor_out': 0xffffffff, + 'check': 0x765e7680, + }) + models.append({ + 'name': 'jam', + 'width': 32, + 'poly': 0x4c11db7, + 'reflect_in': True, + 'xor_in': 0xffffffff, + 'reflect_out': True, + 'xor_out': 0x0, + 'check': 0x340bc6d9, + }) + models.append({ + 'name': 'xfer', + 'width': 32, + 'poly': 0x000000af, + 'reflect_in': False, + 'xor_in': 0x0, + 'reflect_out': False, + 'xor_out': 0x0, + 'check': 0xbd0be338, + }) + models.append({ + 'name': 'crc-64', + 'width': 64, + 'poly': 0x000000000000001b, + 'reflect_in': True, + 'xor_in': 0x0, + 'reflect_out': True, + 'xor_out': 0x0, + 'check': 0x46a5a9388a5beffe, + }) + models.append({ + 'name': 'crc-64-jones', + 'width': 64, + 'poly': 0xad93d23594c935a9, + 'reflect_in': True, + 'xor_in': 0xffffffffffffffff, + 'reflect_out': True, + 'xor_out': 0x0, + 'check': 0xcaa717168609f281, + }) + models.append({ + 'name': 'crc-64-xz', + 'width': 64, + 'poly': 0x42f0e1eba9ea3693, + 'reflect_in': True, + 'xor_in': 0xffffffffffffffff, + 'reflect_out': True, + 'xor_out': 0xffffffffffffffff, + 'check': 0x995dc9bbdf1939fa, + }) + + def names(self): + """ + This function returns the list of supported CRC models. + """ + return [model['name'] for model in self.models] + + def get_params(self, model): + """ + This function returns the parameters of a given model. + """ + model = model.lower() + for i in self.models: + if i['name'] == model: + return i + return None diff --git a/lib/common/pickling.py b/lib/common/pickling.py new file mode 100644 index 0000000000000000000000000000000000000000..0137033fa93b3c84843bdcd07b3cce1a7587e742 --- /dev/null +++ b/lib/common/pickling.py @@ -0,0 +1,72 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import pathlib +import pickle +import os + +TRAILER = '.pkl' + + +class Pickling: + """ + Do to MS Windows OS not forking processes, pickling must occur + to have the variables passed to the process have data. + Simple variables may be passed succesffully without pickling. + """ + + def __init__(self, _config): + self.logger = logging.getLogger(__name__) + self.config = _config + self.temp_dir = _config['paths']['data_dir'] + + def to_pickle(self, _object_to_pickle): + class_name = _object_to_pickle.__class__.__name__ + self.logger.debug('Pickling {}'.format(class_name)) + file_path = self.get_file_path(class_name) + with open(file_path, 'wb') as f: + pickle.dump(_object_to_pickle, f, -1) + f.close() + + def from_pickle(self, _class_name): + self.logger.debug('Unpickling {}'.format(_class_name)) + file_path = self.get_file_path(_class_name) + if os.path.exists(file_path): + with open(file_path, 'rb') as f: + obj_copy = pickle.load(f) + f.close() + return obj_copy + else: + self.logger.warning('Pickling import file does not exist: {}' + .format(file_path)) + return None + + def delete_pickle(self, _class_name): + self.logger.debug('Deleting Pickle File {}'.format(_class_name)) + file_path = self.get_file_path(_class_name) + if os.path.exists(file_path): + os.remove(file_path) + else: + self.logger.warning('Deleting pickle file does not exist: {}' + .format(file_path)) + + def get_file_path(self, classname): + file_path = pathlib.Path(self.temp_dir) \ + .joinpath(classname + TRAILER) + return file_path diff --git a/lib/common/psipdump3.py b/lib/common/psipdump3.py new file mode 100644 index 0000000000000000000000000000000000000000..c053f7e5bcb7719f68b3e2573123fb8f82acddd7 --- /dev/null +++ b/lib/common/psipdump3.py @@ -0,0 +1,623 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# Show PSIP data (and other packets) from MPEG TS streams from ATSC broadcasts +# Warning: very rough, incomplete, you're probably better off using VLC 3.0 (show Media Information, +# go to Codec Details, then scroll down to the EPG (electronic program guide) sections) +# https://gist.github.com/rxseger/caa0f1839ad87d6c1d5aaf667d86bf20 + +import sys +import struct +import binascii + +type_strings = [] + + +def decode_ts_packet(packet): + fields = {} + + # 32-bit field + # comments from https://en.wikipedia.org/wiki/MPEG_transport_stream#Packet + word = struct.unpack('!I', packet[0:4])[0] + + # Bit pattern of 0x47 (ASCII char 'G') + sync = (word & 0xff000000) >> 24 + if sync != 0x47: + print('Bad packet sync byte (desync?):', [packet]) + raise SystemExit + + fields['transport_error_indicator'] = (word & 0x800000) != 0 + # Set when a demodulator can't correct errors from FEC data; indicating the packet is corrupt + # print transport_error_indicator + + # Set when a PES, PSI, or DVB-MIP packet begins immediately following the header. + fields['payload_unit_start_indicator'] = (word & 0x400000) != 0 + + # "Set when the current packet has a higher priority than other packets with the same PID. + fields['transport_priority'] = (word & 0x200000) != 0 + + # Packet Identifier, describing the payload data. + fields['pid'] = (word & 0x1fff00) >> 8 + + # '00' = Not scrambled. + # For DVB-CSA and ATSC DES only:[8] + # '01' (0x40) = Reserved for future use + # '10' (0x80) = Scrambled with even key + # '11' (0xC0) = Scrambled with odd key + fields['scrambling_control'] = (word & 0xc0) >> 6 + + # 01 – no adaptation field, payload only, + # 10 – adaptation field only, no payload, + # 11 – adaptation field followed by payload, + # 00 - RESERVED for future use [9] + fields['adaptation_field_control'] = (word & 0x30) >> 4 + + if fields['adaptation_field_control'] == 1: + has_adapt = False + has_payload = True + elif fields['adaptation_field_control'] == 2: + has_adapt = True + has_payload = False + elif fields['adaptation_field_control'] == 3: + has_adapt = True + has_payload = True + else: + # 00 - RESERVED for future use + # hence tstools "### Packet PID 14ae has adaptation field control = 0 + # which is a reserved value (no payload, no adaptation field)" + # just assume payload, don't spam, and mark it corrupted + has_adapt = False + has_payload = True + fields['corrupted_adaption_control_field'] = True + + # Sequence number of payload packets (0x00 to 0x0F) within each stream (except PID 8191) + # Incremented per-PID, only when a payload flag is set. + fields['cont_counter'] = word & 0xf + + payload_start = 5 + if has_adapt: + adapt_length = struct.unpack('b', bytes([packet[5]]))[0] + if 6 + adapt_length > len(packet): + print("adaptation field beyond length of packet", [packet], adapt_length, len(packet)) + raise SystemExit + + fields['adapt'] = packet[6:6 + adapt_length] + payload_start = 6 + adapt_length + # TODO: decode Adaptation Field Format + + if has_payload: + fields['payload'] = packet[payload_start:] + else: + # No payload here according to the bitfields - save it anyways + extra = packet[payload_start:] + if len(extra) != 0: + fields['corrupt_payload'] = extra + + return fields + + +def ascii_dump(s): + s2 = '' + printable = 0 + for c in s: + if ord(' ') <= c <= ord('~'): + s2 += str(c) + printable += 1 + else: + s2 += '.' + return s2, printable + + +def decode_pat(payload): + t = binascii.b2a_hex(payload) + if t not in type_strings: + type_strings.append(t) + + print('PAT', binascii.b2a_hex(payload)) + # http://www.etherguidesystems.com/Help/SDOs/MPEG/Syntax/TableSections/Pat.aspx + + section_length = (payload[1] & 0xf << 8) | payload[2] # 12-bit field + print(section_length) + + # after extra fields (transport_stream_id to last_section_num, by size, minus CRC-32 at end + program_count = (section_length - 5) / 4 - 1 + + program_map_pids = {} + + print("PAT:") + for i in range(0, int(program_count)): + at = 8 + (i * 4) # skip headers, just get to the program numbers table + program_number = struct.unpack("!H", payload[at:at + 2])[0] + if at + 2 > len(payload): + break + program_map_pid = struct.unpack("!H", payload[at + 2:at + 2 + 2])[0] + + # the pid is only 13 bits, upper 3 bits of this field are 'reserved' (I see 0b111) + reserved = (program_map_pid & 0xe000) >> 13 + program_map_pid &= 0x1fff + + print(i, "%.4x %.4x" % (program_number, program_map_pid)) + program_map_pids[program_map_pid] = program_number + i += 1 + return program_map_pids + + +DESC_NAMES = { + # http://www.etherguidesystems.com/Help/SDOs/MPEG/semantics/mpeg-2/descriptors/Default.aspx + 0: 'Reserved', + 1: 'Reserved', + 2: 'video_stream_descriptor', # 13818-1 + 3: 'audio_stream_descriptor', # '13818-1 + 4: 'hierarchy_descriptor:', # 13818-1 + 5: 'registration_descriptor', # 13818-1 + 6: 'data_stream_alignment_descriptor', # 13818-1 + 7: 'target_background_grid_descriptor', # 13818-1 + 8: 'video_window_descriptor', # 13818-1 + 9: 'CA_descriptor', # 13818-1 + 10: 'ISO_639_language_descriptor', # 13818-1 + 11: 'system_clock_descriptor', # 13818-1 + 12: 'multiplex_buffer_utilization_descriptor', # 13818-1 + 13: 'copyright_descriptor', # 13818-1 + 14: 'maximum_bitrate_descriptor', # 13818-1 + 15: 'private_data_indicator_descriptor', # 13818-1 + 16: 'smoothing_buffer_descriptor', # 13818-1 + 17: 'STD_descriptor', # 3818-1 + 18: 'IBP_descriptor:', # 13818-1 + 27: 'MPEG-4_video_descriptor', # 3818-1 + 28: 'MPEG-4_audio_descriptor', # 13818-1 + 29: 'IOD_descriptor', # 13818-1 + 30: 'SL_descriptor', # 14496-1 + 31: 'FMC_descriptor', # 13818-1 + 32: 'External_ES_ID_descriptor:', # 13818-1 + 33: 'MuxCode_descriptor', # 13818-1 + 34: 'FmxBufferSize_descriptor:', # 14496 + 35: 'MultiplexBuffer_descriptor', # 14496 + 36: 'FlexMuxTiming_descriptor', # 14496 + # http://www.etherguidesystems.com/Help/SDOs/atsc/semantics/descriptors/Default.aspx + 129: 'AC-3 Descriptor', # PMT, EIT A/52 + 20: 'association_tag descriptor', # A/52 + 173: 'ATSC Private Information descriptor', # PMT, EIT 1 or more A/52 + 134: 'Caption Service Descriptor', # PMT, EIT A/65 + 163: 'Component name descriptor', # PMT A/65 + 135: 'Content Advisory Descriptor', # PMT, EIT A/65 + 198: 'Content Identifier descriptor', # PMT, EIT A/57 + 164: 'Data Service descriptor', # PMT, EIT A/65 + 166: 'Download descriptor', # PMT, EIT A/90 + 169: 'DCC Arriving request descriptor', # DCCT A/65 + 168: 'DCC Departing request descriptor', # DCCT A/65 + 178: 'Enhanced Signaling descriptor', # PMT A/53 + 160: 'Extended Channel name descriptor', # VCT A/65 + 167: 'Multiprotocol Encapsulation descriptor', # A/90 + 165: 'pid_count descriptor', # VCT A/65 + 171: 'Genre descriptor', # VCT A/65 + 170: 'Redistribution Control descriptor', # EIT, PMT A/65 + 161: 'Service location descriptor', # VCT A/65 + 128: 'Stuffing Descriptor', # any A/65 + 162: 'Time-shifted service descriptor', # VCT A/65hdtv $ +} + + +# http://www.etherguidesystems.com/Help/SDOs/MPEG/semantics/mpeg-2/descriptor__loop.aspx +# "A descriptor() designation in a table denotes the location of a +# descriptor loop that may contain zero or more individual descriptors" +# http://www.etherguidesystems.com/Help/SDOs/MPEG/semantics/mpeg-2/descriptors/Default.aspx +# a simple repeated type(tag)-length-value format +def decode_descriptors(data): + at = 0 + descriptors = {} + + while True: + if at + 2 > len(data): + break + + tag = data[at] + at += 1 + length = data[at] + at += 1 + value = data[at:at + length] + at += length + + name = DESC_NAMES.get(tag) + if name is None: + name = 'unknown-%d' % (tag,) + print("descriptor %d (%s) %s" % (tag, name, [value])) + descriptors[name] = value + + return descriptors + + +# http://www.etherguidesystems.com/Help/SDOs/MPEG/Syntax/TableSections/PMTs.aspx +def decode_pmt(pid, program, payload): + t = binascii.b2a_hex(payload) + if t not in type_strings: + type_strings.append(t) + print('PMT for Program %d at PID %.4x: %s' % (pid, program, [payload])) + + pcr_pid = struct.unpack("!H", payload[8:10])[0] + reserved = (pcr_pid & 0xe000) >> 13 + pcr_pid &= 0x1fff + # http://www.etherguidesystems.com/Help/SDOs/MPEG/semantics/mpeg-2/PCR_PID.aspx + # program clock reference, not too interesting + # print "PCR PID: %.4x" % (pcr_pid,) + + desc1 = payload[12:] + descriptors = decode_descriptors(desc1) + # TODO: parse descriptors inside loop (per stream), after first descriptor list + + +def decode_psip(payload): + print('Found PSIP full', [payload]) + + table_id = ord(payload[0]) + # print 'PSIP table %.2x' % (table_id,) + + # TODO: decode these + # https://web.archive.org/web/20070221145014/http://www.atsc.org/standards/a_69.pdf pg. 84 table m.1 + # Table M.1 Bit Stream Syntax for the Master Guide Table + if table_id == 0xc7: + print('PSIP MGT', [payload]) + decode_mgt(payload) + # Table M.2 Bit Stream Syntax for the Terrestrial Virtual Channel Table + elif table_id == 0xc8: + print('PSIP VCT', payload.encode('hex'), '\n', 'PSIP VCT', [payload]) + # note: for(i=0; i= 0x006 and x <= 0x00ff: + # return 'Reserved for future ATSC use-%.4x' % (x,) + elif 0x0100 <= x <= 0x017f: + return 'EIT-%d' % (x - 0x0100) + # elif x >= 0x0180 and x <= 0x01ff: + # return 'Reserved for future ATSC use-%.4x' % (x,) + elif 0x0200 <= x <= 0x027f: + return 'Event ETT-%d' % (x - 0x0200) + # elif x >= 0x0280 and x <= 0x0300: + # return 'Reserved for future ATSC use-%.4x' % (x,) + elif 0x0301 <= x <= 0x03ff: + return 'RRT with rating_region-%d' % (x - 0x0301) + elif 0x0400 <= x <= 0x0fff: + return 'User private-%.4x' % (x,) + # elif x >= 0x1000 and x <= 0x13ff: + # return 'Reserved for future ATSC use-%.4x' % (x,) + elif 0x1400 <= x <= 0x14ff: + return 'DCCT with dcc_id-%d' % (x - 0x1400) + # elif x >= 0x1500 and x <= 0x1fff: + # return 'Reserved for future ATSC use-%.4x' % (x,) + else: + return 'Reserved for future ATSC use-%.4x' % (x,) + + +psip_table_pids = {} + + +# Table M.1 Bit Stream Syntax for the Master Guide Table +def decode_mgt(payload): + t = binascii.b2a_hex(payload) + if t not in type_strings: + type_strings.append(t) + print('PSIP MGT', payload.encode('hex')) + assert payload[0] == '\xc7', 'decodeMGT not MGT' + + section_length = struct.unpack('!H', payload[1:3])[0] + section_length &= 0xfff + print('PSIP MGT section_length', section_length) + + table_id_extension = struct.unpack('!H', payload[3:5])[0] + print('PSIP MGT table_id_extension', table_id_extension) + + # reserved, version_number, current_next_indicator + version_etc = ord(payload[5]) + print('PSIP MGT version_etc %.2x' % (version_etc,)) + + # 0x00 in spec + section_number = ord(payload[6]) + assert section_number == 0, 'decodeMGT section_number != 0' + + last_section_number = ord(payload[7]) + assert last_section_number == 0, 'decodeMGT last_section_number != 0' + + protocol_version = ord(payload[8]) + print('PSIP MGT protocol_version', protocol_version) + + # This 16-bit unsigned has a range of 6 – 370 (for terrestrial) and 2 – 370 for cable. + tables_defined = struct.unpack('!H', payload[9:11])[0] + print('PSIP MGT tables_defined', tables_defined) + + at = 11 + tables = [] + for i in range(0, tables_defined): + if at > len(payload): + # off end of payload TODO: probably have a bug in stitching together packets + break + + table_type = struct.unpack('!H', payload[at:at + 2])[0] + table_type_name = decode_mgt_table_type(table_type) + print('PSIP MGT %i table_type %.4x %s' % (i, table_type, table_type_name)) + at += 2 + # table_type here is a 16-bit type: + # https://web.archive.org/web/20070423004711/http://www.atsc.org/standards/a_65cr1_with_amend_1.pdf + # Table 6.3 Table Types, pg. 27 + + table_type_pid = struct.unpack('!H', payload[at:at + 2])[0] + table_type_pid &= 0x1fff + print('PSIP MGT %i table_type_pid %.4x' % (i, table_type_pid)) + at += 2 + + # reserved, table_type_version_number + reserved_version = ord(payload[at]) + print('PSIP MGT %i reserved_version %.2x' % (i, reserved_version)) + at += 1 + + # number of bytes of the table in the referenced pid + if at + 4 > len(payload): + break + number_bytes = struct.unpack('!I', payload[at:at + 4])[0] + print('PSIP MGT %i number_bytes %d' % (i, number_bytes)) + at += 4 + + table_type_descriptors_length = struct.unpack('!H', payload[at:at + 2])[0] + table_type_descriptors_length &= 0xfff + print('PSIP MGT %i table_type_descriptors_length %d' % (i, table_type_descriptors_length)) + at += 2 + + # skip over variable-length descriptors data TODO: decode? + descriptors_data = payload[at:at + table_type_descriptors_length] + at += table_type_descriptors_length + + # save the pid, then we can recognize EIT and ETT tables (non-0x1ffb PIDs) + psip_table_pids[table_type_pid] = table_type_name + + return tables # TODO: other info? + + +# Table M.2 Bit Stream Syntax for the Terrestrial Virtual Channel Table +def decode_vct(payload): + t = binascii.b2a_hex(payload) + if t not in type_strings: + type_strings.append(t) + num_channels_in_section = ord(payload[9:10]) + print('VCT number of channels:', num_channels_in_section) + short_name = payload[11:11 + 13] + short_name = short_name.replace('\0', '') # TODO: decode UTF-16 BE + print('VCT channel short_name:', short_name) + + chinfo = struct.unpack("!I", payload[24:24 + 4])[0] + print("VCT chinfo %.4x" % (chinfo,)) + reserved = (chinfo & 0xf0000000) >> 28 + assert reserved == 0xf, '%x != 0xf in chinfo' % (reserved,) + + major_channel_number = (chinfo & 0x0cff0000) >> 18 + minor_channel_number = (chinfo & 0x000cff00) >> 8 + print("VCT virtual channel %d-%d" % (major_channel_number, minor_channel_number)) + + modulation_mode = chinfo & 0xff + if modulation_mode == 4: + modulation_mode_name = '8VSB' + else: + modulation_mode_name = None + print("VCT modulation mode", modulation_mode, modulation_mode_name) + + carrier_frequency = struct.unpack("!I", payload[28:28 + 4])[0] + print("VCT carrier frequency", carrier_frequency) + + # "The FCC will12 issue a TSID for each digital station upon licensing.", odd for digital, even analog + channel_tsid = struct.unpack("!H", payload[32:32 + 2])[0] + print("VCT channel TSID", channel_tsid) + + program_number = struct.unpack("!H", payload[34:34 + 2])[0] + print("VCT program number", program_number) + + # EMT_location, access_controlled, hidden, reserved, hide_guide, reserved, service_type + flags = struct.unpack("!H", payload[34:34 + 2])[0] + print("VCT misc flags %.4x" % (flags,)) + service_type = flags & 0x3f + # a_69.pdf page 29 + # 1 denotes an NTSC analog service + # 2 denotes an ATSC full digital TV service including video, audio (if present) and data (if present) + # 3 denotes an ATSC audio and data (if present) service + # 4 denotes a ATSC data service + service_types = { + 1: 'NTSC analog', + 2: 'ATSC full digital', + 3: 'ATSC audio and data', + 4: 'ATSC data' + } + + print("VCT service type", service_type, service_types.get(service_type)) + + source_id = struct.unpack("!H", payload[36:36 + 2])[0] + # "The source_id is a critical internal index13 for representing the particular logical channel. + # Broadcasters can assign arbitrary source_id numbers from 1 to 4095 for non registered sources14" + print("VCT source id", source_id, ("%.4x" % source_id)) + + desc_len = struct.unpack("!H", payload[38:38 + 2])[0] + # 6 bits top reserved, lower 10 are descriptors_length + desc_len &= 0x3ff + print("VCT descriptors length", desc_len) + + print("VCT descriptors", decode_descriptors(payload[40:40 + desc_len])) + + # TODO: decode all in loop + + +# Table M.3 Bit Stream Syntax for the Event Information Table +def decode_eit(payload, table_type_name): + t = binascii.b2a_hex(payload) + if t not in type_strings: + type_strings.append(t) + # print 'EIT',table_type_name,[payload] + # not bothering to fully decode table here, just dumping ASCII text good enough to see program names... + # example: + # EIT EIT-0 ('............D........eng....Gag Concert.....eng.?.... + # (../..eng..D........eng....School 2013.....eng.?.... + # (../..eng..D........eng....Marry Me, Mary.....eng.?.... + # (../..eng..............', 75) + # TODO: fully parse + + print('EIT', table_type_name, ascii_dump(payload)) + + +def main(): + if len(sys.argv) > 1: + f = open(sys.argv[1], 'rb') + else: + f = sys.stdin + + packet_count = 0 + + program_map_pids = None + accumulated_1ffb_payload = None + + while True: + # Packets are all 188 bytes + # https://en.wikipedia.org/wiki/MPEG_transport_stream#Important_elements_of_a_transport_stream + packet = f.read(188) + if len(packet) == 0: + break + + if len(packet) != 188: + print("%d != %d" % (len(packet), 188)) + print("Incomplete packet:", [packet]) + raise SystemExit + + fields = decode_ts_packet(packet) + + if fields['transport_error_indicator']: + # print 'corrupted' + # skip corrupted packets TODO: although could TRY to decode, usually fruitless + continue + + # the PAT, of pid 0000, is where it all begins + if fields['pid'] == 0x0000: + program_map_pids = decode_pat(fields['payload']) + + # once we know the PAT, watch for pids that match it + if program_map_pids and fields['pid'] in program_map_pids.keys(): + program = program_map_pids[fields['pid']] + decode_pmt(fields['pid'], program, fields['payload']) + print('PAYLOAD') + print(binascii.b2a_hex(fields['payload'])) + print('PACKET') + print(binascii.b2a_hex(packet)) + + # http://www.bretl.com/mpeghtml/ATSCPSIP.HTM + # "The base tables (Sytem Time Table, STT; Rating Region table, RRT; + # Master Guide Table, MGT; and Virtual Channel Table, VCT) + # are all labeled with the base packet ID (base PID), 0x1FFB." + # Wikipedia confusingly labels 0x1ffb (8187) + # https://en.wikipedia.org/wiki/MPEG_transport_stream#Packet_Identifier_.28PID.29 + # as "Used by DigiCipher 2/ATSC MGT metadata" + # (well, I found it confusing - sounded like it was for encryption, but actually used by ATSC for PSIP!) + # MGT = Master Guide Table, links to all other tables, example: + # http://pastebin.com/XNArF3QZ http://archive.is/tSbne + if fields['pid'] == 0x1ffb: + print('Found 1ffb psip', fields) + if fields['payload_unit_start_indicator']: # TODO: reconstruct all packets + if accumulated_1ffb_payload and len(accumulated_1ffb_payload) != 0: + # when see start of next, decode previous, if have one + # accumulated_1ffb_payload + + def trim_array(a): + s1 = '' + started = False + for elem in a: + if elem is not None: + s1 += elem + started = True + else: + if started: + # print 'Skipping PSIP MGT with corrupted packets',[s] + #: maybe try partial decode anyway, + # but for now hoping get complete uncorrupted stream later + # return False # holes, not full decode + pass # TODO? + return s1 + + print('Accumulated 1ffb psip', accumulated_1ffb_payload) + full = trim_array(accumulated_1ffb_payload) + if full: + decode_psip(full) + + accumulated_1ffb_payload = [None] * 16 + + if accumulated_1ffb_payload is not None: # if didn't start in middle (only accumulate full start->end) + accumulated_1ffb_payload[fields['cont_counter']] = fields['payload'] + + elif fields['pid'] in psip_table_pids.keys(): + table_type_name = psip_table_pids[fields['pid']] + print('TABLE TYPE NAME %.4x %s' % (fields['pid'], table_type_name)) + + if table_type_name == 'Channel EIT' or table_type_name.startswith('EIT-'): + if 'payload' in fields: + # Event Information Tables + decode_eit(fields['payload'], table_type_name) + else: + # TODO + pass + + if fields.get('payload'): + s, printable = ascii_dump(fields['payload']) + # show packet payloads that probably are text + if printable > 100: + print("PID w/ text: %.4x %s" % (fields['pid'], s)) + # print [fields['payload']] + + packet_count += 1 + if packet_count % 100000 == 0: + print('#####################################################################') + print('', file=sys.stderr) + for t in type_strings: + print(t, file=sys.stderr) + print('#####################################################################') + + print("Read %s packets\n\n" % (packet_count,)) + print('#####################################################################') + for t in type_strings: + print(t, file=sys.stderr) + print('#####################################################################\n\n') + + +# TODO: decode by packet identifiers +# https://en.wikipedia.org/wiki/MPEG_transport_stream#Packet_Identifier_.28PID.29 +# 32-8186 0x0020-0x1FFA May be assigned as needed to Program Map Tables, elementary streams and other data tables +# 8188-8190 0x1FFC-0x1FFE May be assigned as needed to Program Map Tables, elementary streams and other data tables +# PAT (pid 0000) -> +# PMT +# NIT (from PAT or 0x0010) +# ./tstools/bin/tsreport mpeg.ts -justpid 0x1e00 |grep -v adapt|unhex + +main() diff --git a/lib/common/string_obj.py b/lib/common/string_obj.py new file mode 100644 index 0000000000000000000000000000000000000000..ff7ec3427ff85cbb4f31ad944a96491be5ba1fcc --- /dev/null +++ b/lib/common/string_obj.py @@ -0,0 +1,34 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + + +class StringObj: + + def __init__(self): + self.byte_string = None + + def terminate(self): + self.byte_string = None + + @property + def data(self): + return self.byte_string + + @data.setter + def data(self, _data): + self.byte_string = _data diff --git a/lib/common/tmp_mgmt.py b/lib/common/tmp_mgmt.py new file mode 100644 index 0000000000000000000000000000000000000000..7b03043ec5feb79249c792c8ad59d3494cd93e77 --- /dev/null +++ b/lib/common/tmp_mgmt.py @@ -0,0 +1,110 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import gzip +import logging +import os +import pathlib +import shutil +import time +import urllib.request +import zipfile + + +import lib.common.utils as utils +from lib.common.decorators import handle_url_except + + +class TMPMgmt: + + def __init__(self, _config): + self.logger = logging.getLogger(__name__) + self.config = _config + + @handle_url_except() + def download_file(self, _url, _retries, _folder, _filename, _file_type): + if _filename == None: + _filename = '{}{}'.format(time.time(), _file_type) + if _folder is None: + save_path = pathlib.Path( + self.config['paths']['tmp_dir']) \ + .joinpath(_filename) + else: + save_path = pathlib.Path( + self.config['paths']['tmp_dir']) \ + .joinpath(_folder) \ + .joinpath(_filename) + buf_size = 2 * 16 * 16 * 1024 + + if not save_path.parent.is_dir(): + save_path.parent.mkdir() + h = {'User-agent': utils.DEFAULT_USER_AGENT} + req = urllib.request.Request(_url, headers=h) + with urllib.request.urlopen(req) as resp: + with open(save_path, 'wb') as out_file: + while True: + chunk = resp.read(buf_size) + if not chunk: + break + out_file.write(chunk) + return save_path + + def extract_gzip(self, _in_filename): + try: + out_filename = _in_filename.with_suffix('') + with gzip.open(_in_filename, 'rb') as f_in: + with open(out_filename, 'wb') as f_out: + shutil.copyfileobj(f_in, f_out) + return out_filename + except (gzip.BadGzipFile, FileNotFoundError) as ex: + raise exceptions.CabernetException( + 'Unable to gunzip File, {} {}' \ + .format(_in_filename, str(ex))) + + def extract_zip(self, _in_filename, outfile=None, is_single_file=False): + try: + if out_file is None: + out_folder = os.path.dirname(_filename) + with zipfile.ZipFile(_filename, 'r') as z: + files = z.namelist() + if is_single_file and len(files) > 1: + raise exceptions.CabernetException( + 'Zip file contains more than one file, aborting, {}' \ + .format(files)) + top_folder = files[0] + z.extractall(out_folder) + return pathlib.Path(out_folder, top_folder) + except (zipfile.BadZipFile, FileNotFoundError) as ex: + raise exceptions.CabernetException( + 'Unable to unzip File, {} {}' \ + .format(_filename, str(ex))) + + def cleanup_tmp(self, folder=None): + self.logger.debug('Cleaning up tmp folder, subfolder {}'.format(folder)) + if folder is None: + dir = pathlib.Path(self.config['paths']['tmp_dir']) + for files in os.listdir(dir): + path = os.path.join(dir, files) + try: + shutil.rmtree(path) + except OSError: + os.remove(path) + else: + dir = pathlib.Path(self.config['paths']['tmp_dir']) \ + .joinpath(folder) + shutil.rmtree(dir) diff --git a/lib/common/utils.py b/lib/common/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..26c673555956505b76c588e4852127f28a652ea2 --- /dev/null +++ b/lib/common/utils.py @@ -0,0 +1,361 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + + +import datetime +import glob +import linecache +import logging +import logging.config +import mimetypes +import ntpath +import os +import pathlib +import platform +import re +import shutil +import socket +import struct +import sys +import time +import tracemalloc + +import lib.common.exceptions as exceptions + +VERSION = '0.9.15.01' +CABERNET_URL = 'https://github.com/cabernetwork/cabernet' +CABERNET_ID = 'cabernet' +CABERNET_REPO = 'manifest.json' +DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/131.0' +PLUGIN_DATA = 'Wawc9dxf2ivj5lmunpq4hrbsktgyXz01e3Y6o7Z8+/' + + +def get_version_str(): + return VERSION + +logger = None +LOG_LVL_NOTICE = 25 +LOG_LVL_TRACE = 5 +SEARCH_VERSION = re.compile('^([\\d]+)\\.([\\d]+)\\.([\\d]+)(?:\\.([\\d]+))*(?:[\\D]+(\\d)+)*') + +def get_version_index(_ver): + """ + Based on the version string will calculate a number representing the version, + which can be used to compare versions. Ignores any text after the fourth number + format a.b.c.d or a.b.c + """ + m = re.findall(SEARCH_VERSION, _ver) + d1, d2, d3, d4, d5 = m[0] + v_int = ((((int(d1)*100)+int(d2 or 0))*100)+int(d3 or 0))*100+int(d4 or 0)+int(d5 or 0)/100 + return v_int + + +def logging_setup(_config): + global logger + if os.environ.get('LOGS_DIR') is None: + if _config['paths']['logs_dir'] is not None: + os.environ['LOGS_DIR'] = _config['paths']['logs_dir'] + try: + logging.config.fileConfig(fname=_config['paths']['config_file']) + except PermissionError as e: + logging.critical(e) + raise e + if str(logging.getLevelName('NOTICE')).startswith('Level'): + logging.addLevelName(LOG_LVL_NOTICE, 'NOTICE') + def notice(self, message, *args, **kws): + if self.isEnabledFor(LOG_LVL_NOTICE): + self._log(LOG_LVL_NOTICE, message, args, **kws) + logging.Logger.notice = notice + if str(logging.getLevelName('TRACE')).startswith('Level'): + logging.addLevelName(LOG_LVL_TRACE, 'TRACE') + def trace(self, message, *args, **kws): + if self.isEnabledFor(LOG_LVL_TRACE): + self._log(LOG_LVL_TRACE, message, args, **kws) + logging.Logger.trace = trace + if str(logging.getLevelName('NOTUSED')).startswith('Level'): + try: + logging.config.fileConfig(fname=_config['paths']['config_file']) + except FileNotFoundError: + if _config['handler_filehandler']['enabled']: + logging.warning('Unable to create cabernet.log in the data/logs area with File Logging enabled.') + except PermissionError as e: + logging.critical(e) + raise e + logging.addLevelName(100, 'NOTUSED') + + logger = logging.getLogger(__name__) + +def clean_exit(exit_code=0): + try: + sys.stderr.flush() + sys.stdout.flush() + except BrokenPipeError: + pass + sys.exit(exit_code) + + +def block_print(): + sys.stdout = open(os.devnull, 'w') + + +def enable_print(): + sys.stdout = sys.__stdout__ + + +def str2bool(s): + return str(s).lower() in ['true', '1', 'yes', 'on'] + + +def tm_parse(tm): + tm_date = datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=tm / 1000) + tm = str(tm_date.strftime('%Y%m%d%H%M%S +0000')) + return tm + + +def convert_to_utc(tm): + """ + Given a datetime obj with a timezone, convert it to UTC. + """ + tm_blank = tm.replace(tzinfo=datetime.timezone.utc) + tm_utc = tm + (tm_blank - tm) + return tm_utc.replace(tzinfo=datetime.timezone.utc) + + + +def tm_local_parse(tm): + tm_date = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + datetime.timedelta(seconds=tm / 1000) + tm = str(tm_date.astimezone().strftime('%Y%m%d%H%M%S %z')) + return tm + + +def date_parse(date_secs, format_str): + if not date_secs: + return date_secs + dt_date = datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=date_secs / 1000) + dt_str = str(dt_date.strftime(format_str)) + return dt_str + +def date_obj_parse(date_obj, format_str): + if not date_obj: + return date_obj + dt_str = str(date_obj.strftime(format_str)) + return dt_str + +def is_time_between(begin_time, end_time, check_time=None): + """ + Check if current GMT time is between 10:30a and 4:30p + EX: is_time_between(time(10,30), time(16,30)) + """ + check_time = check_time or datetime.datetime.utcnow().time() + if begin_time < end_time: + return check_time >= begin_time and check_time <= end_time + else: # crosses midnight + return check_time >= begin_time or check_time <= end_time + + +def is_file_expired(filepath, days=0, hours=0): + if not os.path.exists(filepath): + return True + current_time = datetime.datetime.utcnow() + file_time = datetime.datetime.utcfromtimestamp(os.path.getmtime(filepath)) + if days == 0: + if int((current_time - file_time).total_seconds() / 3600) > hours: + return True + elif (current_time - file_time).days > days: + return True + return False + + +def merge_dict(d1, d2, override=False, ignore_conflicts=False): + for key in d2: + if key in d1: + if isinstance(d1[key], dict) and isinstance(d2[key], dict): + merge_dict(d1[key], d2[key], override, ignore_conflicts) + elif d1[key] == d2[key]: + pass + elif override: + d1[key] = d2[key] + elif not ignore_conflicts: + raise exceptions.CabernetException('Conflict when merging dictionaries {}'.format(str(key))) + else: + d1[key] = d2[key] + + return d1 + +def rename_dict_key(_old_key, _new_key, _dict): + """ + renames a key in a dict without losing the order + """ + return { key if key != _old_key else _new_key: value for key, value in _dict.items()} + + +def get_ip(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + # doesn't even have to be reachable + s.connect(('10.255.255.255', 1)) + ip = s.getsockname()[0] + except Exception: + ip = '127.0.0.1' + finally: + s.close() + return ip + + +def wrap_chnum(_chnum, _namespace, _instance, _config): + """ + Adds prefix and suffix to chnum. If prefix is a integer, then + will add the prefix to the chnum instead of using it like a string. + """ + inst_config_sect = instance_config_section(_namespace, _instance) + prefix = _config[inst_config_sect]['epg-prefix'] + suffix = _config[inst_config_sect]['epg-suffix'] + if prefix is None: + prefix = "" + if suffix is None: + suffix = "" + try: + ch_int = int(prefix) + ch_split = _chnum.split('.', 1) + ch_int += int(ch_split[0]) + if len(ch_split) == 2: + ch_str = str(ch_int) + '.' + ch_split[1] + else: + ch_str = str(ch_int) + except ValueError: + ch_str = prefix + _chnum + ch_str += suffix + return ch_str + + +def instance_config_section(_namespace, _instance): + return _namespace.lower() + '_' + _instance + + +def process_image_url(_config, _thumbnail_url): + global logger + if _thumbnail_url is not None and _thumbnail_url.startswith('file://'): + filename = ntpath.basename(_thumbnail_url) + mime_lookup = mimetypes.guess_type(filename) + new_filename = filename.replace(' ','') + if mime_lookup[0] is not None and \ + mime_lookup[0].startswith('image'): + old_path = _thumbnail_url.replace('file://', '') + new_path = pathlib.Path(_config['paths']['data_dir'], + 'web', 'temp') + if not new_path.exists(): + os.makedirs(new_path) + new_path = new_path.joinpath(new_filename) + if not new_path.exists(): + try: + shutil.copyfile(old_path, new_path) + except FileNotFoundError: + logging.warning('FileNotFoundError: Image not found: {}'.format(old_path)) + return '/temp/FILENOTFOUND' + except OSError as e: + try: + if platform.system() in ['Windows']: + # standard windows path exception. remove '/' + shutil.copyfile(old_path[1:], new_path) + else: + logging.warning('OSError:{}'.format(e)) + return '/temp/FILENOTFOUND' + except FileNotFoundError: + logging.warning('FileNotFoundError: Image file not found: {}'.format(old_path[1:])) + return '/temp/FILENOTFOUND' + return '/temp/'+new_filename + else: + return '/temp/NOTANIMAGE' + else: + return _thumbnail_url + +def cleanup_web_temp(_config): + dir = _config['paths']['data_dir'] + filelist = glob.glob(os.path.join(dir, 'web', 'temp', '*')) + for f in filelist: + if os.path.isfile(f): + os.remove(f) + +# MEMORY USAGE + +def start_mem_trace(_config): + if _config['main']['memory_usage']: + logger.warning('starting tracemalloc {}'.format(tracemalloc.is_tracing())) + tracemalloc.start() + +def end_mem_trace(_config): + if _config['main']['memory_usage'] and tracemalloc.is_tracing(): + snapshot = tracemalloc.take_snapshot() + tracemalloc.stop() + return snapshot + else: + return None + +def display_top(_config, snapshot, key_type='lineno', limit=3): + if _config['main']['memory_usage'] and snapshot is not None: + snapshot = snapshot.filter_traces(( + tracemalloc.Filter(False, ""), + tracemalloc.Filter(False, ""), + )) + top_stats = snapshot.statistics(key_type) + + logging.debug('pid:{} Top {} lines'.format(os.getpid(), limit)) + for index, stat in enumerate(top_stats[:limit], 1): + frame = stat.traceback[0] + # replace "/path/to/module/file.py" with "module/file.py" + filename = os.sep.join(frame.filename.split(os.sep)[-2:]) + logging.debug("#%s: %s:%s: %.1f KiB" + % (index, filename, frame.lineno, stat.size / 1024)) + line = linecache.getline(frame.filename, frame.lineno).strip() + if line: + logging.debug(' %s' % line) + + other = top_stats[limit:] + if other: + size = sum(stat.size for stat in other) + logging.debug("%s other: %.1f KiB" % (len(other), size / 1024)) + total = sum(stat.size for stat in top_stats) + logging.debug("Total allocated size: %.1f KiB" % (total / 1024)) + + +# BYTE METHODS + +def set_u8(integer): + return struct.pack('B', integer) + + +def set_u16(integer): + return struct.pack('>H', integer) + + +def set_u32(integer): + return struct.pack('>I', integer) + + +def set_u64(integer): + return struct.pack('>Q', integer) + + +# HDHR requires a null byte at the end most of the time +def set_str(string, add_null): + # places the length in a single byte, the string and then a null byte if add_null is true + if add_null: + return struct.pack('B%dsB' % (len(string)), len(string) + 1, string, 0) + else: + return struct.pack('B%ds' % (len(string)), len(string), string) + diff --git a/lib/common/xmltv.py b/lib/common/xmltv.py new file mode 100644 index 0000000000000000000000000000000000000000..72183b5e1fbda9cd5de241ae3846b89779640d19 --- /dev/null +++ b/lib/common/xmltv.py @@ -0,0 +1,345 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import datetime +import logging +import re +from xml.etree import ElementTree + + +import lib.common.utils as utils +import lib.common.exceptions as exceptions +from lib.common.tmp_mgmt import TMPMgmt + +TMP_FOLDER = 'xmltv' + +class XMLTV: + """ + Currently only handles program ingest and not the channel ingest. + When a xmltv only plugin is created, then the channel ingest will be needed. + The limitation is the xmltv plugin will not understand stream urls, so its + just an epg. + """ + + def __init__(self, _config, _url, _file_type): + global TMP_FOLDER + self.logger = logging.getLogger(__name__) + self.url = _url + self.file_type = _file_type + self.config = _config + self.iter_is_programme = True + self.interator = None + self.tmp_mgmt = TMPMgmt(self.config) + self.has_future_dates = False + self.start_date = None + self.file_compressed = self.tmp_mgmt.download_file(self.url, 2, TMP_FOLDER, None, self.file_type) + if self.file_compressed is None: + self.file = None + raise exceptions.CabernetException('Unable to obtain XMLTV File {}' \ + .format(self.url)) + else: + self.file = self.extract_file(self.file_compressed, self.file_type) + self.context = None + self.root_elem = None + + def __iter__(self): + self.context = ElementTree.iterparse(self.file, events=('start', 'end',)) + self.has_future_dates = False + self.iterator = iter(self.context) + event, self.root_elem = next(self.iterator, (None, None)) + return self + + def __next__(self): + prog = None + while prog is None: + elem = self.get_next_prog_elem() + if elem is None: + return prog + else: + prog = self.get_program(elem) + return prog + + def cleanup_tmp_folder(self): + global TMP_FOLDER + self.tmp_mgmt.cleanup_tmp(TMP_FOLDER) + + def get_next_prog_elem(self): + while True: + event, elem = next(self.iterator, (None, None)) + if event is None: + return None + if event == 'start': + if elem.tag == 'programme': + return elem + + + def set_iter_type(self, _is_programme=True): + """ + Is either programme (True) or channel (False) + """ + self.iter_is_programme = _is_programme + + def set_date(self, _start_date): + """ + When returning programs, the date filters the programme return + where the start date is in the date range (UTC) + this is a datetime.date object + """ + self.start_date = _start_date + + def extract_file(self, _filename, _file_type): + if _file_type == '.zip': + return self.tmp_mgmt.extract_zip(_filename) + elif _file_type == '.gz': + return self.tmp_mgmt.extract_gzip(_filename) + else: + return _filename + + def get_program(self, elem): + program = None + dt = self.str_to_datetime(elem.attrib['start']) + dt_utc = utils.convert_to_utc(dt) + if self.start_date is None or self.start_date == dt_utc.date(): + program = {'channel': elem.attrib['channel'], 'progid': None, + 'start': elem.attrib['start'], 'stop': elem.attrib['stop'], + 'length': 0, 'title': None, 'subtitle': None, + 'entity_type': None, 'desc': 'Not Available', + 'short_desc': 'Not Available', + 'video_quality': None, 'cc': False, 'live': False, + 'finale': False, 'premiere': False, 'air_date': None, + 'formatted_date': None, 'icon': None, 'rating': None, + 'is_new': False, 'genres': None, 'directors': None, + 'actors': None, 'season': None, 'episode': None, + 'se_common': None, 'se_xmltv_ns': None, + 'se_progid': None} + while True: + not_done = self.get_next_elem(program) + self.root_elem.clear() + if not not_done: + return program + elif dt_utc.date() > self.start_date: + self.has_future_dates = True + return program + + def get_next_elem(self, _program): + while True: + event, elem = next(self.iterator, (None, None)) + if event is None: + return False + if event == 'start': + if elem.tag == 'title': + _program['title'] = self.get_p_title(elem) + return True + elif elem.tag == 'sub-title': + _program['subtitle'] = self.get_p_sub_title(elem) + return True + elif elem.tag == 'desc': + _program['desc'] = self.get_p_desc(elem) + _program['short_desc'] = _program['desc'] + return True + elif elem.tag == 'length': + _program['length'] = self.get_p_length(elem) + return True + elif elem.tag == 'icon': + _program['icon'] = self.get_p_icon(elem) + return True + elif elem.tag == 'previously-shown': + _program['is_new'] = self.get_p_previously_shown(elem) + return True + elif elem.tag == 'new': + _program['is_new'] = self.get_p_new(elem) + return True + elif elem.tag == 'premiere': + _program['premiere'] = self.get_p_premiere(elem) + return True + elif elem.tag == 'subtitles': + _program['cc'] = self.get_p_subtitles(elem) + return True + elif elem.tag == 'rating': + _program['rating'] = self.get_p_rating(elem) + return True + elif elem.tag == 'video': + _program['video_quality'] = self.get_p_video_quality(elem) + return True + elif elem.tag == 'live': + _program['live'] = self.get_p_live(elem) + return True + elif elem.tag == 'finale': + _program['finale'] = self.get_p_finale(elem) + return True + elif elem.tag == 'category': + if _program['genres'] is None: + _program['genres'] = self.get_p_category(elem) + return True + elif elem.tag == 'credits': + credits = self.get_p_credits(elem) + if credits: + if len(credits['actors']) != 0: + _program['actors'] = credits['actors'] + if len(credits['directors']) != 0: + _program['directors'] = credits['directors'] + return True + elif elem.tag == 'date': + p_date = self.get_p_date(elem) + if p_date: + _program['air_date'] = p_date + if len(p_date) == 4: + _program['formatted_date'] = p_date + else: + _program['formatted_date'] = datetime.datetime.strptime( + p_date, '%Y%m%d').strftime('%Y/%m/%d') + return True + elif elem.tag == 'episode-num': + episode_num = self.get_p_episode_num(elem) + if episode_num: + if episode_num['system'] == 'common' or \ + episode_num['system'] == 'SxxExx': + ep_num = episode_num['text'] + _program['se_common'] = ep_num + nums = re.findall('\\d+', ep_num) + if len(nums) < 2: + _program['episode'] = nums[0] + else: + _program['episode'] = nums[1] + _program['season'] = nums[0] + elif episode_num['system'] == 'dd_progid': + ep_num = episode_num['text'] + _program['se_progid'] = ep_num + _program['progid'] = ep_num.replace('.', '') + if _program['episode'] is None: + nums = int(re.findall('\\d+$', ep_num)[0]) + if nums != 0: + _program['episode'] = nums + elif episode_num['system'] == 'xmltv_ns': + _program['se_xmltv_ns'] = episode_num['text'] + return True + if event == 'end' and elem.tag == 'programme': + return False + + def str_to_datetime(self, date_str): + return datetime.datetime.strptime(date_str, '%Y%m%d%H%M%S %z') + + def get_ch_channel(self, elem): + return elem.attrib['id'] + + def get_ch_display_name(self, elem): + event, elem = next(self.iterator, (None, None)) + return elem.text + + def get_ch_icon(self, elem): + return elem.attrib['src'] + + def get_p_title(self, elem): + event, elem = next(self.iterator, (None, None)) + return elem.text + + def get_p_sub_title(self, elem): + event, elem = next(self.iterator, (None, None)) + return elem.text + + def get_p_desc(self, elem): + event, elem = next(self.iterator, (None, None)) + if elem.text is None: + return 'Not Available' + else: + return elem.text + + def get_p_length(self, elem): + event, elem = next(self.iterator, (None, None)) + if elem.attrib['units'] == 'minutes': + return int(elem.text) + elif elem.attrib['units'] == 'hours': + return int(elem.text) * 60 + elif elem.attrib['units'] == 'seconds': + return round(int(elem.text) / 60) + else: + self.logger.warning('Unknown XMLTV program length {}:{}' + .format(elem.attrib['units'], elem.text)) + return None + + def get_p_category(self, elem): + # multiple hits + event, elem = next(self.iterator, (None, None)) + return [ elem.text ] + + def get_p_icon(self, elem): + return elem.attrib['src'] + + def get_p_previously_shown(self, elem): + return False + + def get_p_new(self, elem): + return True + + def get_p_premiere(self, elem): + return True + + def get_p_subtitles(self, elem): + return True + + def get_p_episode_num(self, elem): + # multiple hits + system = elem.attrib['system'] + event, elem = next(self.iterator, (None, None)) + return {'system':system, 'text':elem.text} + + def get_p_rating(self, elem): + event, elem = next(self.iterator, (None, None)) + event, elem = next(self.iterator, (None, None)) + return elem.text + + def get_p_credits(self, elem): + # director + # actor + # producer + event, elem = next(self.iterator, (None, None)) + actors = [] + directors = [] + while True: + if event == 'end': + if elem.tag == 'credits': + break + else: + if elem.tag == 'actor': + actors.append(elem.text) + elif elem.tag == 'director': + directors.append(elem.text) + event, elem = next(self.iterator, (None, None)) + return {'actors': actors, 'directors': directors} + + def get_p_date(self, elem): + # formats: YYYY or YYYYMMDD that can include dashes that are removed + event, elem = next(self.iterator, (None, None)) + p_date = elem.text + if '-' in p_date: + p_date = p_date.replace('-', '') + return p_date + + def get_p_video_quality(self, elem): + event, elem = next(self.iterator, (None, None)) + event, elem = next(self.iterator, (None, None)) + return elem.text + + def get_p_live(self, elem): + return True + # missing from epg2xml + + def get_p_finale(self, elem): + return True + # missing from epg2xml + diff --git a/lib/config/__init__.py b/lib/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..00f185dcf4693df998589eddf37bf793e97fb746 --- /dev/null +++ b/lib/config/__init__.py @@ -0,0 +1 @@ +import lib.config.configform_html diff --git a/lib/config/config_callbacks.py b/lib/config/config_callbacks.py new file mode 100644 index 0000000000000000000000000000000000000000..7c6b9057ff07e414a172a239152e473a5ef73c09 --- /dev/null +++ b/lib/config/config_callbacks.py @@ -0,0 +1,360 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import getpass +import importlib +import importlib.resources +import logging +import logging.config +import os +import pathlib +import platform +import urllib.request +import uuid + +import lib.common.utils as utils +import lib.common.encryption as encryption +import lib.config.config_defn as config_defn +import lib.clients.hdhr.hdhr_server as hdhr_server +from lib.db.db_config_defn import DBConfigDefn +from lib.db.db_scheduler import DBScheduler +from lib.db.db_channels import DBChannels +from lib.clients.web_handler import WebHTTPHandler + +try: + import cryptography + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + from cryptography.fernet import Fernet + + CRYPTO_LOADED = True +except ImportError: + CRYPTO_LOADED = False + +ENCRYPT_STRING = 'ENC::' + + +def noop(_config_obj, _section, _key): + pass + + +def logging_refresh(_config_obj, _section, _key): + logging.config.fileConfig(fname=_config_obj.data['paths']['config_file'], disable_existing_loggers=False) + req = urllib.request.Request('http://{}:{}/logreset'.format( + _config_obj.data['web']['plex_accessible_ip'], str(_config_obj.data['web']['plex_accessible_port']))) + with urllib.request.urlopen(req) as resp: + content = resp.read() + + +def logging_enable(_config_obj, _section, _key): + # update the config_file to enable or disable the log + # [logger_root] + # handlers = loghandler, filehandler + handler_list = [] + if _config_obj.data['handler_filehandler']['enabled']: + handler_list.append('filehandler') + if _config_obj.data['handler_loghandler']['enabled']: + handler_list.append('loghandler') + handlers = ','.join(handler_list) + _config_obj.write('logger_root', 'handlers', handlers) + logging_refresh(_config_obj, _section, _key) + + +def set_version(_config_obj, _section, _key): + _config_obj.data[_section][_key] \ + = utils.get_version_str() + + +def set_system(_config_obj, _section, _key): + _config_obj.data[_section][_key] \ + = platform.system() + + +def set_python_version(_config_obj, _section, _key): + _config_obj.data[_section][_key] \ + = platform.python_version() + + +def set_user(_config_obj, _section, _key): + _config_obj.data[_section][_key] \ + = getpass.getuser() + + +def set_os(_config_obj, _section, _key): + _config_obj.data[_section][_key] \ + = platform.version() + + +def set_path(_config_obj, _section, _key, _base_dir, _folder): + if not _config_obj.data[_section][_key]: + _config_obj.data[_section][_key] = pathlib.Path(_base_dir).joinpath(_folder) + else: + _config_obj.data[_section][_key] = pathlib.Path(_config_obj.data[_section][_key]) + if not _config_obj.data[_section][_key].is_dir(): + _config_obj.data[_section][_key].mkdir() + _config_obj.data[_section][_key] = str(os.path.abspath(_config_obj.data[_section][_key])) + + +def set_data_path(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + set_path(_config_obj, _section, _key, + _config_obj.data['paths']['main_dir'], 'data') + + +def set_logs_path(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + set_path(_config_obj, _section, _key, + _config_obj.data['paths']['data_dir'], 'logs') + + +def set_thumbnails_path(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + set_path(_config_obj, _section, _key, + _config_obj.data['paths']['data_dir'], 'thumbnails') + + +def set_temp_path(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + set_path(_config_obj, _section, _key, + _config_obj.data['paths']['data_dir'], 'tmp') + + +def set_database_path(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + set_path(_config_obj, _section, _key, + _config_obj.data['paths']['data_dir'], 'db') + + +def set_backup_path(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + set_path(_config_obj, _section, _key, + _config_obj.data['paths']['data_dir'], 'backups') + + +def set_configdefn_path(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + _config_obj.data[_section][_key] = _config_obj.defn_json.defn_path + + +def set_main_path(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + _config_obj.data['paths']['main_dir'] = _config_obj.script_dir + + +def set_ffmpeg_path(_config_obj, _section, _key): + if not _config_obj.data[_section][_key]: + if platform.system() in ['Windows']: + base_ffmpeg_dir \ + = pathlib.Path(_config_obj.script_dir).joinpath('ffmpeg/bin') + if base_ffmpeg_dir.is_dir(): + _config_obj.data[_section][_key] \ + = str(pathlib.Path(base_ffmpeg_dir).joinpath('ffmpeg.exe')) + else: + _config_obj.data[_section][_key] = 'ffmpeg.exe' + _config_obj.logger.notice( + 'ffmpeg_path does not exist in [cabernet]/ffmpeg/bin, will use PATH env to find ffmpeg.exe') + else: + _config_obj.data[_section][_key] = 'ffmpeg' + + +def set_ffprobe_path(_config_obj, _section, _key): + if not _config_obj.data[_section][_key]: + if platform.system() in ['Windows']: + base_ffmpeg_dir \ + = pathlib.Path(_config_obj.script_dir).joinpath('ffmpeg/bin') + if base_ffmpeg_dir.is_dir(): + _config_obj.data[_section][_key] \ + = str(pathlib.Path(base_ffmpeg_dir).joinpath('ffprobe.exe')) + else: + _config_obj.data[_section][_key] = 'ffprobe.exe' + _config_obj.logger.notice( + 'ffprobe_path does not exist in [cabernet]/ffmpeg/bin, will use PATH env to find ffprobe.exe') + else: + _config_obj.data[_section][_key] = 'ffprobe' + + +def set_streamlink_path(_config_obj, _section, _key): + if not _config_obj.data[_section][_key]: + if platform.system() in ['Windows']: + _config_obj.data[_section][_key] \ + = 'streamlink.exe' + _config_obj.logger.notice( + 'streamlink_path does not exist in PATH to find streamlink.exe') + else: + streamlink_file = os.path.expanduser('~/.local/bin/streamlink') + if os.path.isfile(streamlink_file): + _config_obj.data[_section][_key] = streamlink_file + else: + _config_obj.data[_section][_key] = 'streamlink' + + +def set_pdata(_config_obj, _section, _key): + if not _config_obj.data[_section][_key]: + _config_obj.data[_section][_key] = \ + utils.PLUGIN_DATA + config_defn.PLUGIN_DATA + + +def check_encryption(_config_obj, _section, _key): + if not CRYPTO_LOADED: + return 'python cryptography module not installed, unable to encrypt' + + +def load_encrypted_setting(_config_obj, _section, _key): + if CRYPTO_LOADED and _config_obj.data['main']['use_encryption']: + if _config_obj.data['main']['encrypt_key'] is None: + _config_obj.data['main']['encrypt_key'] = encryption.set_fernet_key().decode('utf-8') + + if _config_obj.data[_section][_key] is not None: + if _config_obj.data[_section][_key].startswith(ENCRYPT_STRING): + # encrypted + _config_obj.data[_section][_key] \ + = encryption.decrypt( + _config_obj.data[_section][_key], + _config_obj.data['main']['encrypt_key']) + if _config_obj.data[_section][_key] is None: + _config_obj.logger.error( + 'Unable to decrypt password. ' + + 'Try updating password in config file in clear text') + else: + # not encrypted + clear_pwd = _config_obj.data[_section][_key] + encrypted_pwd = encryption.encrypt( + _config_obj.data[_section][_key], + _config_obj.data['main']['encrypt_key']) + _config_obj.write(_section, _key, encrypted_pwd) + _config_obj.data[_section][_key] = clear_pwd + + +def set_ip(_config_obj, _section, _key): + if _config_obj.data[_section][_key] == '0.0.0.0': + _config_obj.data['web']['bind_ip'] = '0.0.0.0' + if _config_obj.data['web']['plex_accessible_ip'] == '0.0.0.0': + _config_obj.data['web']['plex_accessible_ip'] \ + = utils.get_ip() + else: + _config_obj.data['web']['bind_ip'] \ + = _config_obj.data[_section][_key] + if _config_obj.data['web']['plex_accessible_ip'] == '0.0.0.0': + _config_obj.data['web']['plex_accessible_ip'] \ + = _config_obj.data[_section][_key] + + +def set_netmask(_config_obj, _section, _key): + if not _config_obj.data[_section][_key]: + _config_obj.data[_section][_key] = \ + '{}/32'.format(_config_obj.data['web']['plex_accessible_ip']) + + +def enable_hdhr(_config_obj, _section, _key): + if not _config_obj.data[_section][_key]: + if _config_obj.data['hdhomerun']['udp_netmask'] is None: + _config_obj.data[_section][_key] = True + return 'ERROR:: [hdhomerun][udp_netmask] must be set when HDHomeRun is enabled, reverted save' + + +def enable_ssdp(_config_obj, _section, _key): + if not _config_obj.data[_section][_key]: + if _config_obj.data['ssdp']['udp_netmask'] is None: + _config_obj.data[_section][_key] = True + return 'ERROR:: [ssdp][udp_netmask] must be set when HDHomeRun is enabled, reverted save' + + +def set_hdhomerun_id(_config_obj, _section, _key): + if _config_obj.data[_section][_key] is None: + _config_obj.write( + _section, _key, hdhr_server.hdhr_gen_device_id()) + elif not hdhr_server.hdhr_validate_device_id( + _config_obj.data[_section][_key]): + _config_obj.write( + _section, _key, hdhr_server.hdhr_gen_device_id()) + + +def set_uuid(_config_obj, _section, _key): + if _config_obj.data["main"]["uuid"] is None: + _config_obj.write('main', 'uuid', str(uuid.uuid1()).upper()) + + +def update_instance_label(_config_obj, _section, _key): + value = _config_obj.data[_section][_key] + db_confdefn = DBConfigDefn(_config_obj.data) + areas = db_confdefn.get_area_by_section(_section) + if len(areas) > 1: + results = 'WARNING: There is more than one section named {}'.format(_section) + elif len(areas) == 0: + return + else: + results = None + section_data = db_confdefn.get_one_section_dict(areas[0], _section) + section_data[_section]['label'] = value + db_confdefn.add_section(areas[0], _section, section_data[_section]) + # when instance label is updated, all tasks for that instance are removed + # a restart is needed. Note added to config label + db_scheduler = DBScheduler(_config_obj.data) + namespace, instance = _section.split('_', 1) + tasks = db_scheduler.get_tasks_by_name(namespace, instance) + for task in tasks: + WebHTTPHandler.sched_queue.put({'cmd': 'deltask', 'taskid': task['taskid']}) + return results + + +def update_channel_num(_config_obj, _section, _key): + starting_num = _config_obj.data[_section][_key] + init_num = starting_num + is_changed = False + namespace_l, instance = _section.split('_', 1) + db_channels = DBChannels(_config_obj.data) + namespaces = db_channels.get_channel_names() + namespace = {n['namespace']: n for n in namespaces if namespace_l == n['namespace'].lower()}.keys() + if len(namespace) == 0: + return 'ERROR: Bad namespace' + namespace = list(namespace)[0] + ch_list = db_channels.get_channels(namespace, instance) + for ch in ch_list.values(): + if starting_num != -1: + if ch[0]['display_number'] != starting_num: + ch[0]['display_number'] = starting_num + db_channels.update_channel_number(ch[0]) + starting_num += 1 + + if init_num == -1: + return 'Renumbered channels back to default'.format(_section, _key) + else: + return 'Renumbered channels starting at {}'.format(_section, _key, init_num) + + +def set_theme_folders(_defn, _config, _section, _key): + """ + To make this work, the themes folder must be static and known + since theme folder list is set before the paths are initialized + """ + theme_list = [] + themes_path = 'lib.web.htdocs.modules.themes' + for folder in sorted(importlib.resources.contents(themes_path)): + if folder.startswith('__'): + continue + try: + importlib.resources.read_text(themes_path, folder) + except (IsADirectoryError, PermissionError): + theme_list.append(folder) + except UnicodeDecodeError: + continue + _defn['general']['sections']['display']['settings']['theme']['values'] = theme_list + theme_default = _defn['general']['sections']['display']['settings']['theme']['default'] + if theme_default not in theme_list: + _defn['general']['sections']['display']['settings']['theme']['default'] = theme_list[0] diff --git a/lib/config/config_defn.py b/lib/config/config_defn.py new file mode 100644 index 0000000000000000000000000000000000000000..2e2a2a017b281c993734172652d28a48113f73f7 --- /dev/null +++ b/lib/config/config_defn.py @@ -0,0 +1,288 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import importlib +import json +import logging +import threading +from importlib import resources + +import lib.common.utils as utils +from lib.db.db_config_defn import DBConfigDefn + +CONFIG_DEFN_PATH = 'lib.resources.config_defn' +PLUGIN_DATA = 'AOEPFQGLIKMCNRJSBTHUDV' + + +def load_default_config_defns(): + """ loads all definition files from the default + folder and returns the ConfigDefn object + """ + defn_obj = ConfigDefn() + for defn_file in sorted( + importlib.resources.contents(CONFIG_DEFN_PATH)): + if str(defn_file).endswith('.json'): + defn_obj.merge_defn_file(CONFIG_DEFN_PATH, defn_file) + return defn_obj + + +class ConfigDefn: + + def __init__(self, _defn_path=None, _defn_file=None, _config=None, _is_instance=False): + self.logger = None + self.config_defn = {} + self.config = None + self.db = None + self.is_instance_defn = _is_instance + self.restricted_items = [] + if _config: + self.set_config(_config) + if _defn_file and _defn_path: + self.merge_defn_file(_defn_path, _defn_file) + + def set_config(self, _config): + self.config = _config + if self.db is None: + self.db = DBConfigDefn(self.config) + if self.config_defn: + self.save_defn_to_db() + self.logger = logging.getLogger(__name__) + + def merge_defn_file(self, _defn_path, _defn_file): + """ Merges a definition file into the current object + """ + json_file = resources.read_text(_defn_path, _defn_file) + defn = json.loads(json_file) + self.call_ondefnload(defn) + self.merge_defn_dict(defn) + + def merge_defn_dict(self, _defn_dict): + """ Merges a definition file into the current object + """ + self.config_defn = utils.merge_dict(self.config_defn, _defn_dict) + self.update_restricted_items(_defn_dict) + if self.db is not None: + self.save_defn_to_db(_defn_dict) + + def merge_defn_obj(self, _defn_obj): + """ will merge and terminate defn object + """ + self.config_defn = utils.merge_dict(self.config_defn, _defn_obj.config_defn, ignore_conflicts=True) + self.update_restricted_items(_defn_obj.config_defn) + + def garbage_collect(self): + self.logger.debug('garbage collecting for Thread:{}'.format(threading.get_ident())) + self.config_defn = None + + def get_default_config(self): + """ + JSON format: [module]['sections'][section]['settings'][setting][metadata] + section is the section in the ini file + setting is the name in the ini file + """ + config_defaults = {} + if self.db is not None: + areas = self.get_areas() + for area in areas: + area_dict = self.get_defn(area) + defaults_dict = self.get_default_config_area(area, area_dict) + config_defaults = utils.merge_dict(config_defaults, defaults_dict) + else: + for area, area_dict in self.config_defn.items(): + defaults_dict = self.get_default_config_area(area, area_dict) + config_defaults = utils.merge_dict(config_defaults, defaults_dict) + return config_defaults + + def get_default_config_area(self, _area, _area_dict=None): + config_defaults = {} + if _area_dict is None: + area_dict = self.get_defn(_area) + else: + area_dict = _area_dict + + for section in list(area_dict['sections'].keys()): + if section not in list(config_defaults.keys()): + config_defaults[section] = {} + for setting in list(area_dict['sections'][section]['settings'].keys()): + value = area_dict['sections'][section]['settings'][setting]['default'] + config_defaults[section][setting] = value + return config_defaults + + def get_defn(self, _area): + area_dict = self.db.get_area_dict(_area) + if not area_dict: + return + area_dict = area_dict[0] + sections = self.db.get_sections_dict(_area) + area_dict['sections'] = sections + return area_dict + + def get_areas(self): + return self.db.get_areas() + + def call_oninit(self, _config_obj): + for module in list(self.config_defn.keys()): + for section in list(self.config_defn[module]['sections'].keys()): + for key, settings in list(self.config_defn[module]['sections'][section]['settings'].items()): + if 'onInit' in settings: + self.call_function(settings['onInit'], section, key, _config_obj) + + def call_onchange(self, _area, _updated_data, _config_obj): + results = '' + area_data = self.get_defn(_area) + for section, section_data in area_data['sections'].items(): + if section in _updated_data: + for key, setting_data in section_data['settings'].items(): + if key in _updated_data[section] and \ + _updated_data[section][key][1] and \ + 'onChange' in setting_data: + status = self.call_function(setting_data['onChange'], section, key, _config_obj) + if status is None: + results += '
  • [{}][{}] implemented
  • '.format(section, key) + else: + results += '
  • [{}][{}] {}
  • '.format(section, key, status) + return results + + def call_ondefnload(self, _defn): + for module in list(_defn.keys()): + for section in list(_defn[module]['sections'].keys()): + for key, settings in list(_defn[module]['sections'][section]['settings'].items()): + if 'onDefnLoad' in settings: + self.call_ondefnload_function(settings['onDefnLoad'], section, key, self.config, _defn) + + def call_function(self, _func_str, _section, _key, _config_obj): + """ calls the function in the definition. If relative path + then assume module is relative to the plugins directory + """ + mod_name, func_name = _func_str.rsplit('.', 1) + if mod_name.startswith('.'): + try: + mod = importlib.import_module( + mod_name, + package=_config_obj.data['paths']['internal_plugins_pkg']) + except ModuleNotFoundError as e: + mod = importlib.import_module( + mod_name, + package=_config_obj.data['paths']['external_plugins_pkg']) + else: + mod = importlib.import_module(mod_name) + func = getattr(mod, func_name) + return func(_config_obj, _section, _key) + + def call_ondefnload_function(self, _func_str, _section, _key, _config, _defn): + """ calls the function in the definition. If relative path + then assume module is relative to the plugins directory + """ + mod_name, func_name = _func_str.rsplit('.', 1) + if mod_name.startswith('.'): + try: + mod = importlib.import_module( + mod_name, + package=_config['paths']['internal_plugins_pkg']) + except ModuleNotFoundError as e: + mod = importlib.import_module( + mod_name, + package=_config['paths']['external_plugins_pkg']) + else: + mod = importlib.import_module(mod_name) + func = getattr(mod, func_name) + return func(_defn, _config, _section, _key) + + def save_defn_to_db(self, _delta_defn=None): + if _delta_defn: + delta_defn = _delta_defn + else: + delta_defn = self.config_defn + for area, area_data in delta_defn.items(): + if 'icon' in area_data: + self.db.add_area(area, area_data) + for section, section_data in area_data['sections'].items(): + if self.is_instance_defn: + self.db.add_instance(area, section, section_data) + else: + self.db.add_section(area, section, section_data) + + def save_instance_defn_to_db(self, _delta_defn=None): + if _delta_defn: + delta_defn = _delta_defn + else: + delta_defn = self.config_defn + for area, area_data in delta_defn.items(): + if 'icon' in area_data: + self.db.add_area(area, area_data) + for section, section_data in area_data['sections'].items(): + self.db.add_instance(area, section, section_data) + + def get_type(self, _section, _key, _value): + """ Returns the expected type of the setting + """ + for module in list(self.config_defn.keys()): + for section in list(self.config_defn[module]['sections'].keys()): + if section == _section: + for setting in list(self.config_defn[module]['sections'][section]['settings'].keys()): + if setting == _key: + return self.config_defn[module]['sections'][section]['settings'][setting]['type'] + return None + + def validate_list_item(self, _section, _key, _value): + """ for list settings, will determine if the value + is in the list + """ + for module in list(self.config_defn.keys()): + for section in list(self.config_defn[module]['sections'].keys()): + if section == _section: + for setting in list(self.config_defn[module]['sections'][section]['settings'].keys()): + if setting == _key: + if _value in str( + self.config_defn[module]['sections'][section]['settings'][setting]['values']): + return True + else: + return False + return None + + def update_restricted_items(self, _defn_file): + for area, area_data in _defn_file.items(): + self.update_restricted_items_area(area_data) + + def update_restricted_items_area(self, _defn_area): + for section, section_data in _defn_area['sections'].items(): + for key, settings in section_data['settings'].items(): + if settings['level'] == 4: + self.restricted_items.append([section, key]) + elif 'hidden' in settings and settings['hidden']: + self.restricted_items.append([section, key]) + + def get_restricted_items(self): + if not self.restricted_items: + area_list = self.db.get_areas() + for area in area_list: + area_dict = self.get_defn(area) + self.update_restricted_items_area(area_dict) + return self.restricted_items + + @property + def defn_path(self): + return CONFIG_DEFN_PATH + + def terminate(self): + self.db.close() + self.config_defn = None + self.config = None + self.db = None + self.restricted_items = None + self.logger.debug('Database terminated for thread:{}'.format(threading.get_ident())) diff --git a/lib/config/configform_html.py b/lib/config/configform_html.py new file mode 100644 index 0000000000000000000000000000000000000000..79fcc86bdcb319c9d2437e7ad4e9552868909997 --- /dev/null +++ b/lib/config/configform_html.py @@ -0,0 +1,284 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +from lib.web.pages.templates import web_templates +from lib.common.decorators import getrequest +from lib.common.decorators import postrequest + + +@getrequest.route('/api/configform') +def get_configform_html(_webserver): + if 'area' in _webserver.query_data: + configform = ConfigFormHTML() + form = configform.get(_webserver.plugins.config_obj.defn_json.get_defn( + _webserver.query_data['area']), _webserver.query_data['area'], _webserver.plugins.config_obj.data) + _webserver.do_mime_response(200, 'text/html', form) + else: + _webserver.do_mime_response(404, 'text/html', web_templates['htmlError'].format('404 - Area Not Found')) + + +@postrequest.route('/api/configform') +def post_configform_html(_webserver): + if _webserver.config['web']['disable_web_config']: + _webserver.do_mime_response( + 501, 'text/html', web_templates['htmlError'] + .format('501 - Config pages disabled. ' + 'Set [web][disable_web_config] to False in the config file to enable')) + else: + # Take each key and make a [section][key] to store the value + config_changes = {} + area = _webserver.query_data['area'][0] + del _webserver.query_data['area'] + del _webserver.query_data['name'] + del _webserver.query_data['instance'] + for key in _webserver.query_data: + key_pair = key.split('-', 1) + if key_pair[0] not in config_changes: + config_changes[key_pair[0]] = {} + config_changes[key_pair[0]][key_pair[1]] = _webserver.query_data[key] + results = _webserver.plugins.config_obj.update_config(area, config_changes) + _webserver.do_mime_response(200, 'text/html', results) + + +class ConfigFormHTML: + + def __init__(self): + self.area = None + self.config_defn = None + self.config = None + + def get(self, _config_defn, _area, _config): + self.area = _area + self.config = _config + self.config_defn = _config_defn + return ''.join([self.header, self.body]) + + @property + def header(self): + return ''.join([ + '', + '', + '', + 'Settings Editor', + '', + '', + '', + '']) + + @property + def title(self): + return ''.join([ + '
    ', + '

    Settings Editor - ', + self.config_defn['label'], + '

    ', + self.config_defn['description'], + '
      ', + '
    • ', + '
    ' + ]) + + @property + def tabs(self): + active_tab = ' activeTab' + area_html = ''.join(['']) + return area_html + + @property + def forms(self): + area_html = '' + for section in self.config_defn['sections'].keys(): + area_html = ''.join([area_html, self.get_form(section)]) + return area_html + + def get_form(self, _section): + input_html = "*** UNKNOWN INPUT TYPE ***" + section_data = self.config_defn['sections'][_section] + form_html = ''.join([ + '
    ', + section_data['description'], + '
    ' + ]) + section_html = '' + subsection = None + is_section_new = False + + plugin_image = '' + for setting, setting_data in section_data['settings'].items(): + if setting_data['level'] == 4: + continue + title = '' + readonly = '' + + new_section = None + if '-' in setting: + new_section = setting.split('-', 1)[0] + if new_section != subsection and new_section is not None: + is_section_new = True + subsection = new_section + + background_color = '#F0F0F0' + if setting_data['help'] is not None: + title = ''.join([' title="', setting_data['help'], '"']) + + if 'writable' in setting_data and not setting_data['writable']: + readonly = ' readonly' + background_color = '#C0C0C0' + + if setting_data['type'] == 'string' \ + or setting_data['type'] == 'path': + input_html = ''.join([ + '']) + + if setting_data['type'] == 'password': + input_html = ''.join([ + '']) + + elif setting_data['type'] == 'integer' \ + or setting_data['type'] == 'float': + input_html = ''.join([ + '']) + + elif setting_data['type'] == 'boolean': + if 'writable' in setting_data and not setting_data['writable']: + readonly = ' disabled' + + input_html = ''.join([ + '' + '' + ]) + + elif setting_data['type'] == 'list': + dlsetting = '' + if section_data['name'] == 'display' and setting == 'display_level': + dlsetting = ' class="dlsetting" ' + + option_html = ''.join(['']) + for value in setting_data['values']: + option_html += ''.join([ + '']) + input_html = ''.join([ + '']) + + elif setting_data['type'] == 'image': + if setting != 'plugin_image': + input_html = ''.join([ + '']) + else: + input_html = None + img_size = self.lookup_config_size() + plugin_image = ''.join([ + '
    ', + '', + '
    ' + ]) + if is_section_new: + is_section_new = False + section_html = ''.join([section_html, + '']) + if input_html: + section_html = ''.join([section_html, + '']) + return ''.join([ + form_html, section_html, '

    ', subsection.upper(), '

    ', input_html, + '
    ', plugin_image, + '
    ', + '
    ']) + + def lookup_config_size(self): + size_text = self.config['channels']['thumbnail_size'] + if size_text == 'None': + return 0 + elif size_text == 'Tiny(16)': + return 16 + elif size_text == 'Small(48)': + return 48 + elif size_text == 'Medium(128)': + return 128 + elif size_text == 'Large(180)': + return 180 + elif size_text == 'X-Large(270)': + return 270 + elif size_text == 'Full-Size': + return None + else: + return None + + + @property + def body(self): + if self.config_defn is None: + return ''.join([ + '
    ', + '
    ']) + else: + return ''.join([ + self.title, + self.tabs, + self.forms, + '
    ', + '

    Not all configuration parameters are listed. ', + 'Edit the config file directly to change any parameters.

    ', + '
    ']) diff --git a/lib/config/user_config.py b/lib/config/user_config.py new file mode 100644 index 0000000000000000000000000000000000000000..397f51edf711994a1b5d279ce13ace8d7df9f16f --- /dev/null +++ b/lib/config/user_config.py @@ -0,0 +1,377 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import copy +import configparser +import json +import logging +import pathlib +import os +import re +import shutil + +import lib.common.utils as utils +import lib.config.config_defn as config_defn +from lib.common.utils import clean_exit +from lib.common.decorators import getrequest +from lib.db.db_config_defn import DBConfigDefn +from lib.web.pages.templates import web_templates +from lib.common.decorators import Backup +from lib.common.decorators import Restore + +CONFIG_BKUP_NAME = 'backups-config_ini' +CONFIG_FILENAME = 'config.ini' + + +def get_config(script_dir, opersystem, args): + return TVHUserConfig(script_dir, opersystem, args) + + +@getrequest.route('/config.json') +def config_json(_webserver): + if _webserver.config['web']['disable_web_config']: + _webserver.do_mime_response(501, 'text/html', web_templates['htmlError'] + .format('501 - Config pages disabled.' + ' Set [web][disable_web_config] to False in the config file to enable')) + else: + _webserver.do_mime_response(200, 'application/json', + json.dumps(_webserver.plugins.config_obj.filter_config_data())) + + +class TVHUserConfig: + + def __init__(self, _script_dir=None, _opersystem=None, _args=None, _config=None): + self.logger = None + self.defn_json = None + self.db = None + self.script_dir = str(_script_dir) + self.defn_json = config_defn.load_default_config_defns() + self.data = self.defn_json.get_default_config() + self.config_handler = configparser.ConfigParser(interpolation=None) + + if _script_dir is not None: + config_file = TVHUserConfig.get_config_path(_script_dir, _args) + self.import_config(config_file) + self.defn_json.call_oninit(self) + utils.logging_setup(self.data) + # at this point, the config is setup + self.db = DBConfigDefn(self.data) + self.db.reinitialize_tables() + self.defn_json.set_config(self.data) + self.defn_json.save_defn_to_db() + else: + self.set_config(_config) + self.defn_json.garbage_collect() + self.db = DBConfigDefn(self.data) + self.db.add_config(self.data) + + def refresh_config_data(self): + self.data = self.db.get_config() + + def set_config(self, _config): + self.data = copy.deepcopy(_config) + self.config_handler.read(self.data['paths']['config_file']) + self.logger = logging.getLogger(__name__) + + def init_logger_config(self): + log_sections = ['loggers', 'logger_root', 'handlers', 'formatters', + 'handler_filehandler', 'handler_loghandler', + 'formatter_extend', 'formatter_simple'] + for section in log_sections: + try: + self.config_handler.add_section(section) + except configparser.DuplicateSectionError: + pass + for key, value in self.data[section].items(): + self.config_handler.set(section, key, str(value)) + with open(self.data['paths']['config_file'], 'w') as config_file: + self.config_handler.write(config_file) + utils.logging_setup(self.data) + + def import_config(self, config_file): + self.config_handler.read(config_file) + self.data['paths']['config_file'] = str(config_file) + try: + utils.logging_setup(self.data) + except (KeyError, RuntimeError): + self.init_logger_config() + self.logger = logging.getLogger(__name__) + self.logger.info("Loading Configuration File: " + str(config_file)) + + search_section_name = re.compile('^[a-zA-Z0-9]+_?[a-zA-Z0-9]+$') + + for each_section in self.config_handler.sections(): + lower_section = each_section.lower() + if each_section != lower_section: + self.logger.error('ERROR: ALL SECTIONS IN THE config.ini MUST BE LOWER CASE. Found: {}' + .format(each_section)) + continue + m = re.match(search_section_name, each_section) + if m is None: + self.logger.error('ERROR: INVALID SECTION NAME IN THE config.ini. Found: {}' + .format(each_section)) + continue + + if lower_section not in self.data.keys(): + self.data.update({lower_section: {}}) + for (each_key, each_val) in self.config_handler.items(each_section): + lower_key = each_key.lower() + self.data[lower_section][lower_key] = \ + self.fix_value_type(lower_section, lower_key, each_val) + + @staticmethod + def get_config_path(_script_dir, args=None): + config_file = None + poss_config = None + if args is not None and args.cfg: + config_file = pathlib.Path(str(args.cfg)) + else: + for x in [CONFIG_FILENAME, 'data/' + CONFIG_FILENAME]: + poss_config = pathlib.Path(_script_dir).joinpath(x) + if os.path.exists(poss_config): + config_file = poss_config + break + if config_file: + if not os.path.exists(config_file): + try: + # config file missing, create it + f = open(config_file, 'wb') + f.close() + except PermissionError as e: + print('1 ERROR: {} unable to create {}'.format(str(e), config_file)) + else: + # create one in the data folder + try: + data_folder = pathlib.Path(_script_dir).joinpath('data') + if not data_folder.exists(): + os.mkdir(data_folder) + f = open('data/' + CONFIG_FILENAME, 'wb') + config_file = pathlib.Path(data_folder).joinpath(CONFIG_FILENAME) + f.close() + except PermissionError as e: + print('2 ERROR: {} unable to create {}'.format(str(e), poss_config)) + + if config_file and os.path.exists(config_file): + return config_file + else: + print('ERROR: Config file missing {} {}, Exiting...'.format(config_file, poss_config)) + clean_exit(1) + + def fix_value_type(self, _section, _key, _value): + try: + val_type = self.defn_json.get_type(_section, _key, _value) + if val_type == 'boolean': + return self.config_handler.getboolean(_section, _key) + elif val_type == 'list': + if isinstance(_value, str) and _value.isdigit(): + _value = int(_value) + if not self.defn_json.validate_list_item(_section, _key, _value): + logging.info('INVALID VALUE ({}) FOR CONFIG ITEM [{}][{}]' + .format(_value, _section, _key)) + return _value + elif val_type == 'integer': + return int(_value) + elif val_type == 'float': + return float(_value) + elif val_type is None: + return _value + else: + return _value + except (configparser.NoOptionError, configparser.NoSectionError, TypeError): + return _value + except ValueError: + return None + + # removes sensitive data from config and returns a copy + def filter_config_data(self): + restricted_list = self.defn_json.get_restricted_items() + filtered_config = copy.deepcopy(self.data) + for item in restricted_list: + del filtered_config[item[0]][item[1]] + return filtered_config + + def detect_change(self, _section, _key, _updated_data): + current_value = self.data[_section][_key] + if type(current_value) is int: + if _updated_data[_section][_key][0] is not None: + _updated_data[_section][_key][0] = int(_updated_data[_section][_key][0]) + elif type(current_value) is bool: + _updated_data[_section][_key][0] = bool(int(_updated_data[_section][_key][0])) + elif type(current_value) is str: + pass + elif current_value is None: + pass + else: + self.logger.debug('unknown value type for [{}][{}] type is {}' + .format(_section, _key, type(self.data[_section][_key]))) + + if self.data[_section][_key] != _updated_data[_section][_key][0]: + if len(_updated_data[_section][_key]) > 1: + _updated_data[_section][_key][1] = True + else: + _updated_data[_section][_key].append(True) + else: + if len(_updated_data[_section][_key]) > 1: + _updated_data[_section][_key][1] = False + else: + _updated_data[_section][_key].append(False) + + def merge_config(self, _delta_config_dict): + self.data = utils.merge_dict(self.data, _delta_config_dict, ignore_conflicts=True) + + def update_config(self, _area, _updated_data): + # make sure the config_handler has all the data from the file + self.config_handler.read(self.data['paths']['config_file']) + + results = '

    Status Results

      ' + + area_data = self.defn_json.get_defn(_area) + for section, section_data in area_data['sections'].items(): + if section in _updated_data: + for setting, setting_data in section_data['settings'].items(): + if setting in _updated_data[section]: + if setting_data['level'] == 4: + pass + elif 'writable' in setting_data and not setting_data['writable']: + if setting in _updated_data[section]: + _updated_data[section][setting].append(False) + elif 'hidden' in setting_data and setting_data['hidden']: + if _updated_data[section][setting][0] is None: + _updated_data[section][setting].append(False) + else: + _updated_data[section][setting].append(True) + _updated_data[section][setting].append(True) + else: + self.detect_change(section, setting, _updated_data) + + # save the changes to config.ini and self.data + config_defaults = self.defn_json.get_default_config_area(_area) + for key in _updated_data.keys(): + results += self.save_config_section(key, _updated_data, config_defaults) + + results += self.defn_json.call_onchange(_area, _updated_data, self) + with open(self.data['paths']['config_file'], 'w') as config_file: + self.config_handler.write(config_file) + + # need to inform things that changes occurred... + restart = False + + self.db.add_config(self.data) + if restart: + results += '
    Service may need to be restarted if not all changes were implemented

    ' + else: + results += '

    ' + return results + + def save_config_section(self, _section, _updated_data, _config_defaults): + results = '' + for (key, value) in _updated_data[_section].items(): + if len(value) > 1 and value[1]: + if value[0] is None: + # use default and remove item from config.ini + try: + self.config_handler.remove_option(_section, key) + except configparser.NoSectionError: + pass + self.data[_section][key] \ + = _config_defaults[_section][key] + self.logger.debug( + 'Config Update: Removed [{}][{}]'.format(_section, key)) + results += '
  • Removed [{}][{}] from {}, using default value
  • '\ + .format(_section, key, CONFIG_FILENAME) + else: + # set new value + if len(_updated_data[_section][key]) == 3: + self.logger.debug( + 'Config Update: Changed [{}][{}] updated' + .format(_section, key)) + else: + self.logger.debug( + 'Config Update: Changed [{}][{}] to {}' + .format(_section, key, _updated_data[_section][key][0])) + + try: + self.config_handler.set( + _section, key, str(_updated_data[_section][key][0])) + except configparser.NoSectionError: + self.config_handler.add_section(_section) + self.config_handler.set( + _section, key, str(_updated_data[_section][key][0])) + if self.data.get(_section) is not None: + self.data[_section][key] = _updated_data[_section][key][0] + if len(_updated_data[_section][key]) == 3: + results += '
  • Updated [{}][{}] updated
  • ' \ + .format(_section, key) + else: + results += '
  • Updated [{}][{}] to {}
  • ' \ + .format(_section, key, _updated_data[_section][key][0]) + return results + + def write(self, _section, _key, _value): + if _section not in self.data.keys(): + self.data.update({_section: {}}) + + self.data[_section][_key] = _value + try: + if _value is None: + self.config_handler.remove_option(_section, _key) + return + self.config_handler.set(_section, _key, str(_value)) + except configparser.NoSectionError: + self.config_handler.add_section(_section) + self.config_handler.set(_section, _key, str(_value)) + with open(self.data['paths']['config_file'], 'w') as config_file: + self.config_handler.write(config_file) + if self.db: + self.db.add_config(self.data) + + +class BackupConfig: + + def __init__(self, _config): + self.logger = logging.getLogger(__class__.__name__) + self.config = _config + + @Backup(CONFIG_BKUP_NAME) + def backup(self, backup_folder): + self.logger.debug('Running backup for {}'.format(CONFIG_FILENAME)) + try: + if not os.path.isdir(backup_folder): + os.mkdir(backup_folder) + backup_file = pathlib.Path(backup_folder, CONFIG_FILENAME) + shutil.copyfile(self.config['paths']['config_file'], + backup_file) + except PermissionError as e: + self.logger.warning(e) + self.logger.warning('Unable to make backups') + + @Restore(CONFIG_BKUP_NAME) + def restore(self, backup_folder): + self.logger.debug('Running restore for {}'.format(CONFIG_FILENAME)) + if not os.path.isdir(backup_folder): + msg = 'Backup folder does not exist: {}'.format(backup_folder) + self.logger.warning(msg) + return msg + backup_file = pathlib.Path(backup_folder, CONFIG_FILENAME) + if not os.path.isfile(backup_file): + msg = 'Backup file does not exist, skipping: {}'.format(backup_file) + self.logger.info(msg) + return msg + shutil.copyfile(backup_file, + self.config['paths']['config_file']) + return CONFIG_FILENAME + ' restored, please restart the app' diff --git a/lib/db/__init__.py b/lib/db/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5820d69cf20aff0a51ce3a0c22ad16d5b33f097c --- /dev/null +++ b/lib/db/__init__.py @@ -0,0 +1 @@ +import lib.db.datamgmt \ No newline at end of file diff --git a/lib/db/datamgmt/__init__.py b/lib/db/datamgmt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f976c77fadaab75a0213a873f5038dde3f87af6b --- /dev/null +++ b/lib/db/datamgmt/__init__.py @@ -0,0 +1,2 @@ +import lib.db.datamgmt.data_mgmt_html + diff --git a/lib/db/datamgmt/backups.py b/lib/db/datamgmt/backups.py new file mode 100644 index 0000000000000000000000000000000000000000..a0624dd8d403576e7244011d786b244889dae7a8 --- /dev/null +++ b/lib/db/datamgmt/backups.py @@ -0,0 +1,207 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import datetime +import glob +import logging +import os +import pathlib +import shutil +import zipfile + +import lib.common.utils as utils +from lib.db.db_scheduler import DBScheduler +from lib.common.decorators import Backup +from lib.common.decorators import Restore +from lib.db.db_config_defn import DBConfigDefn + +BACKUP_FOLDER_NAME = 'CabernetBackup' +CODE_DIRS_TO_IGNORE = ['__pycache__', 'data', '.git', 'ffmpeg', 'streamlink', '.github', 'build', 'misc'] +CODE_FILES_TO_IGNORE = ['config.ini', 'is_container', 'uninst.exe'] + + +def scheduler_tasks(config): + scheduler_db = DBScheduler(config) + if scheduler_db.save_task( + 'Applications', + 'Backup', + 'internal', + None, + 'lib.db.datamgmt.backups.backup_data', + 20, + 'thread', + 'Backs up cabernet data including databases and config' + ): + scheduler_db.save_trigger( + 'Applications', + 'Backup', + 'weekly', + dayofweek='Sunday', + timeofday='02:00' + ) + # Backup.log_backups() + + +def backup_data(_plugins): + b = Backups(_plugins) + b.backup_data() + return True + + +class Backups: + def __init__(self, _plugins): + self.logger = logging.getLogger(__name__) + self.plugins = _plugins + self.config = _plugins.config_obj.data + #if self.config['paths']['external_plugins_pkg'] not in CODE_DIRS_TO_IGNORE: + # CODE_DIRS_TO_IGNORE.append(self.config['paths']['external_plugins_pkg']) + + def backup_data(self): + # get the location where the backups will be stored + # also deal with the number of backup folder limit and clean up + backups_to_retain = self.config['datamgmt']['backups-backupstoretain'] - 1 + backups_location = self.config['datamgmt']['backups-location'] + folderlist = sorted(glob.glob(os.path.join(backups_location, BACKUP_FOLDER_NAME + '*'))) + + while len(folderlist) > backups_to_retain: + try: + shutil.rmtree(folderlist[0]) + except PermissionError as e: + logging.warning(e) + break + folderlist = sorted(glob.glob(os.path.join(backups_location, BACKUP_FOLDER_NAME + '*'))) + new_backup_folder = BACKUP_FOLDER_NAME +'_'+ utils.VERSION + datetime.datetime.now().strftime('_%Y%m%d_%H%M') + new_backup_path = pathlib.Path(backups_location, new_backup_folder) + + for key in Backup.backup2func.keys(): + Backup.call_backup(key, self.config, backup_folder=new_backup_path) + return new_backup_folder + + def restore_data(self, _folder, _key): + """ + key is what the Back and Restore decorators use to lookup the function call_backup + and is also tied to the config defn lookup under datamgmt + """ + full_path = pathlib.Path(self.config['datamgmt']['backups-location'], _folder) + if os.path.isdir(full_path): + return Restore.call_restore(_key, self.config, backup_folder=full_path) + else: + return 'Folder does not exist: {}'.format(full_path) + + def backup_list(self): + """ + A list of dicts that contain what is backed up for use with restore. + """ + db_confdefn = DBConfigDefn(self.config) + dm_section = db_confdefn.get_one_section_dict('general', 'datamgmt') + bkup_defn = {} + for key in Restore.restore2func.keys(): + bkup_defn[key] = dm_section['datamgmt']['settings'][key] + return bkup_defn + + def backup_all(self): + backup_folder = self.backup_data() + if backup_folder is None: + return False + return self.backup_code(backup_folder) + + def backup_code(self, _backup_folder): + """ + Zips up the code with an exclusion regexp that is compiled. + using os.walk() along with the regexp, it will gen a zip file in the backup_folder + """ + # default compression is 6 + zf = zipfile.ZipFile(pathlib.Path( + self.config['datamgmt']['backups-location'], _backup_folder, + 'cabernet_code.zip'), + 'w', compression=zipfile.ZIP_DEFLATED) + + base_path = os.path.dirname(self.config['paths']['main_dir']) + dir_count = 0 + for dirname, subdirs, files in os.walk( + self.config['paths']['main_dir']): + dir_count += 1 + # max count is normally around 70 folders + if dir_count > 200: + logging.warning('Unexpected folder count exceeded, aborting code backup') + return False + + for d in CODE_DIRS_TO_IGNORE: + if d in subdirs: + subdirs.remove(d) + rel_dirname = dirname.replace(base_path, '.') + zf.write(dirname, arcname=rel_dirname) + for filename in files: + zf.write(os.path.join(dirname, filename), + arcname=pathlib.Path(rel_dirname, filename)) + zf.close() + return True + + def delete_code(self): + for dirname, subdirs, files in os.walk( + self.config['paths']['main_dir']): + for d in CODE_DIRS_TO_IGNORE: + if d in subdirs: + subdirs.remove(d) + for filename in files: + if filename not in CODE_FILES_TO_IGNORE: + try: + os.remove(os.path.join(dirname, filename)) + except PermissionError as ex: + self.logger.notice( + 'Exception: {} Unable to delete file prior to overlaying upgrade' + .format(str(ex))) + return True + + def restore_code(self, _folder): + """ + Provides the folder relative to the tmp folder where the + code to restore resides. The code may contain non-code + files, so standard code filtering is required. + """ + new_code_path = os.path.join(self.config['paths']['tmp_dir'], + _folder) + for dirname, subdirs, files in os.walk(new_code_path): + for d in CODE_DIRS_TO_IGNORE: + if d in subdirs: + subdirs.remove(d) + rel_dirname = dirname.replace(new_code_path, '.') + for filename in files: + os.makedirs(os.path.join(self.config['paths']['main_dir'], rel_dirname), + exist_ok=True) + try: + dest = shutil.move(os.path.join(dirname, filename), + os.path.join(self.config['paths']['main_dir'], rel_dirname)) + except shutil.Error as ex: + self.logger.notice( + 'Exception: {} Unable to overlay new file' + .format(str(ex))) + + def check_code_write_permissions(self): + result = '' + for dirname, subdirs, files in os.walk(self.config['paths']['main_dir']): + for d in CODE_DIRS_TO_IGNORE: + if d in subdirs: + subdirs.remove(d) + if not os.access(dirname, os.W_OK): + self.logger.info('Aborting upgrade, folder not writable: {}'.format(dirname)) + result += '#### Folder not writeable, aborting upgrade. FOLDER: {}
    \r\n'.format(dirname) + if result == '': + return None + else: + return result diff --git a/lib/db/datamgmt/data_mgmt_html.py b/lib/db/datamgmt/data_mgmt_html.py new file mode 100644 index 0000000000000000000000000000000000000000..9226676af533cc061f3042e640bb79468bd19dbb --- /dev/null +++ b/lib/db/datamgmt/data_mgmt_html.py @@ -0,0 +1,510 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import datetime +import glob +import logging +import platform +import re +import shutil +import os + +import lib.db.datamgmt.backups as backups +from lib.common import utils +from lib.common.decorators import getrequest +from lib.common.decorators import postrequest +from lib.db.db_channels import DBChannels +from lib.db.db_epg import DBepg +from lib.db.db_epg_programs import DBEpgPrograms +from lib.db.db_scheduler import DBScheduler +from lib.db.db_plugins import DBPlugins + +BACKUP_FOLDER_NAME = '*Backup' + + +@getrequest.route('/api/datamgmt') +def get_data_mgmt_html(_webserver): + data_mgmt_html = DataMgmtHTML(_webserver.plugins) + if 'delete' in _webserver.query_data: + data_mgmt_html.del_backup(_webserver.query_data['delete']) + html = data_mgmt_html.get() + elif 'restore' in _webserver.query_data: + html = data_mgmt_html.restore_form(_webserver.query_data['restore']) + else: + html = data_mgmt_html.get() + _webserver.do_mime_response(200, 'text/html', html) + + +@postrequest.route('/api/datamgmt') +def post_data_mgmt_html(_webserver): + if 'folder' in _webserver.query_data: + html = restore_from_backup(_webserver.plugins, _webserver.query_data) + elif 'action' in _webserver.query_data: + action = _webserver.query_data['action'][0] + if action == 'reset_channel': + html = reset_channels( + _webserver.plugins.config_obj.data, + _webserver.query_data['name'][0], _webserver.query_data['resetedits'][0]) + elif action == 'reset_epg': + html = reset_epg( + _webserver.plugins.config_obj.data, + _webserver.query_data['name'][0]) + elif action == 'reset_scheduler': + html = reset_sched( + _webserver.plugins.config_obj.data, + _webserver.query_data['name'][0], + _webserver.sched_queue) + elif action == 'del_instance': + html = del_instance( + _webserver.plugins.config_obj.data, + _webserver.query_data['name'][0]) + else: + # database action request + html = 'UNKNOWN action request: {}'.format(_webserver.query_data['action'][0]) + else: + html = 'UNKNOWN REQUEST' + _webserver.do_mime_response(200, 'text/html', html) + + +def reset_channels(_config, _name, _reset_edits): + db_channel = DBChannels(_config) + db_channel.del_status(_name) + if _reset_edits == '1': + db_channel.del_channels(_name, None) + if _name is None: + return 'Channels updated and will refresh all data on next request' + else: + return 'Channels for plugin {} updated and will refresh all data on next request' \ + .format(_name) + + +def reset_epg(_config, _name): + db_epg = DBepg(_config) + db_epg.del_instance(_name, None) + db_epg.set_last_update(_name) + # db_epg_programs = DBEpgPrograms(_config) + # db_epg_programs.del_namespace(_name) + + if _name is None: + return 'EPG updated and will refresh all days on next request' + else: + return 'EPG for plugin {} updated and will refresh all days on next request' \ + .format(_name) + + +def reset_sched(_config, _name, _sched_queue): + db_scheduler = DBScheduler(_config) + tasks = db_scheduler.get_tasks_by_name(_name) + html = '' + for task in tasks: + _sched_queue.put({'cmd': 'deltask', 'taskid': task['taskid']}) + #db_scheduler.del_task(task['area'], task['title']) + html = ''.join([html, + '', task['area'], ':', task['title'], + ' deleted from Scheduler
    ' + ]) + return ''.join([html, + 'Restart the app to re-populate the scheduler with defaults']) + + +def del_instance(_config, _name): + if _name is None: + return 'Instance set to None. No instances deleted' + if ':' not in _name: + return 'Invalid action. Request ignored' + name_inst = _name.split(':', 1) + + html = '' + db_plugins = DBPlugins(_config) + num_del = db_plugins.del_instance(None, name_inst[0], name_inst[1]) + if num_del > 0: + html = ''.join([html, + '', _name, ' deleted from Plugins
    ' + ]) + + db_channels = DBChannels(_config) + db_channels.del_status(name_inst[0], name_inst[1]) + num_del = db_channels.del_channels(name_inst[0], name_inst[1]) + if num_del > 0: + html = ''.join([html, + '', _name, ' deleted from Channels
    ' + ]) + + db_epg = DBepg(_config) + db_epg.del_instance(name_inst[0], name_inst[1]) + if num_del > 0: + html = ''.join([html, + '', _name, ' deleted from EPG
    ' + ]) + + db_programs = DBEpgPrograms(_config) + db_programs.del_namespace(name_inst[0]) + if num_del > 0: + html = ''.join([html, + '', name_inst[0], ' deleted from EPG Programs
    ' + ]) + + db_sched = DBScheduler(_config) + task_list = db_sched.get_tasks_by_name(name_inst[0], name_inst[1]) + for task in task_list: + db_sched.del_task(task['area'], task['title']) + if len(task_list) > 0: + html = ''.join([html, + '', _name, ' deleted from Scheduler
    ' + ]) + return ''.join([html, + 'Restart the app to re-populate the scheduler with defaults']) + + +def restore_from_backup(_plugins, _query_data): + b = backups.Backups(_plugins) + bkup_defn = b.backup_list() + folder = _query_data['folder'][0] + del _query_data['name'] + del _query_data['instance'] + del _query_data['folder'] + html = '' + for restore_key, status in _query_data.items(): + if status[0] == '1': + msg = b.restore_data(folder, restore_key) + if msg is None: + html = ''.join([html, bkup_defn[restore_key]['label'], ' Restored
    ']) + else: + html = ''.join([html, msg, '
    ']) + return html + + +class DataMgmtHTML: + + def __init__(self, _plugins): + self.logger = logging.getLogger(__name__) + self.config = _plugins.config_obj.data + self.bkups = backups.Backups(_plugins) + self.search_date = re.compile('[^_]+(_([\\d.]+[-DEVRC\\d]*))?_(\\d*?_\\d*)') + + def get(self): + return ''.join([self.header, self.body]) + + @property + def header(self): + return ''.join([ + '', + '', + '', + 'Data Management', + '', + '', + '', + '' + ]) + + @property + def title(self): + return ''.join([ + '
    ', + '

    Data Management

    ' + ]) + + @property + def body(self): + return ''.join(['', self.title, self.db_updates, self.backups, + '' + ]) + + @property + def db_updates(self): + html = ''.join([ + '
    ', + '
    ', + '', + '', + '', + '', + ]) + html_select = self.select_reset_channel + html = ''.join([html, html_select, + '
    ', + 'inventory_2', + '
    Reset Channel Data   ', + '', + '
    ', + '
    Next channel request will force pull new data

    ', + '
    ', + '', + '', + '', + '', + ]) + html_select = self.select_reset_epg + html = ''.join([html, html_select, + '
    ', + 'inventory_2', + '
    Reset EPG Data   ', + '', + '
    ', + '
    Next epg request will pull all days

    ', + '
    ', + '', + '', + '', + '', + ]) + html_select = self.select_reset_sched + html = ''.join([html, html_select, + '
    ', + 'inventory_2', + '
    Reset Scheduler Tasks   ', + '', + '
    ', + '
    Scheduler will reload default tasks on next app restart

    ', + '
    ', + '', + '', + '', + '', + ]) + html_select = self.select_del_instance + html = ''.join([html, html_select, + '' + '', + '
    ', + 'inventory_2', + '
    Delete Instance   ', + '', + '
    ', + '
    Deletes the instance data from the database file. Update config.ini, ', + 'as needed, and restart app following a delete


    ', + ]) + return html + + @property + def backups(self): + html = ''.join([ + '
    ', + '', + '', + '' + '', + ]) + backups_location = self.config['datamgmt']['backups-location'] + folderlist = sorted(glob.glob(os.path.join( + backups_location, BACKUP_FOLDER_NAME + '*')), reverse=True) + for folder in folderlist: + filename = os.path.basename(folder) + datetime_str = self.get_backup_date(filename) + if datetime_str is None: + continue + html = ''.join([html, + '', + '', + '', + '', + '' + ]) + html = ''.join([html, + '
    ', + 'Current Backups
    ', + '', + 'folder', + '', + '
    ', datetime_str, '
    ', + '
    ', folder, '
    ', + '', + 'delete_forever
    ', + '
    ' + ]) + return html + + def del_backup(self, _folder): + valid_regex = re.compile('^([a-zA-Z0-9_.-]+$)') + if not valid_regex.match(_folder): + self.logger.info('Invalid backup folder to delete: {}'.format(_folder)) + return + backups_location = self.config['datamgmt']['backups-location'] + f_to_delete = os.path.join(backups_location, _folder) + if os.path.isdir(f_to_delete): + self.logger.info('Deleting backup folder {}'.format(_folder)) + shutil.rmtree(f_to_delete) + else: + self.logger.info('Backup folder not found to delete: {}'.format(_folder)) + + def restore_form(self, _folder): + datetime_str = self.get_backup_date(_folder) + if datetime_str is None: + return 'ERROR - UNKNOWN BACKUP FOLDER' + + html = ''.join([ + '' + '
    ', + '', + '', + '', + '' + '', + '', + '', + '' + '', + '', + '' + ]) + bkup_defn = self.bkups.backup_list() + for key in bkup_defn.keys(): + html = ''.join([html, + '', + '', + '', + '' + ]) + html = ''.join([html, + '
    ', + '', + '
    arrow_back
    ', + 'Backup from: ', datetime_str, '
    ', + 'Select the items to restore
    ', + '', + '
    ', + '', + '', + '', + bkup_defn[key]['label'], + '
    ', + '', + '
    ' + ]) + return html + + def get_backup_date(self, _filename): + try: + m = re.match(self.search_date, _filename) + if m and len(m.groups()) == 3: + ver = m.group(2) + if ver is None: + ver = '' + date_str = m.group(3) + datetime_obj = datetime.datetime.strptime( + date_str, '%Y%m%d_%H%M') + else: + raise ValueError('Filename incorrect format') + except ValueError as e: + self.logger.info('Bad backup folder name {}: {}'.format(_filename, e)) + return None + opersystem = platform.system() + if opersystem in ['Windows']: + return datetime_obj.strftime('%m/%d/%Y, %#I:%M %p ' + str(ver)) + else: + return datetime_obj.strftime('%m/%d/%Y, %-I:%M %p ' + str(ver)) + + @property + def select_reset_channel(self): + db_channel = DBChannels(self.config) + plugins_channel = db_channel.get_channel_names() + html_option = ''.join([ + 'Reset Edits:   ', + 'Plugin: ']) + + @property + def select_reset_epg(self): + db_epg = DBepg(self.config) + db_epg_programs = DBEpgPrograms(self.config) + + plugin_epg = db_epg.get_epg_names() + plugin_programs = db_epg_programs.get_program_names() + plugin_epg_names = [s['namespace'] for s in plugin_epg] + plugin_programs_names = [s['namespace'] for s in plugin_programs] + plugin_list = list(set(plugin_epg_names + plugin_programs_names)) + + html_option = ''.join([ + 'Plugin: ']) + + @property + def select_reset_sched(self): + db_sched = DBScheduler(self.config) + plugins_sched = db_sched.get_task_names() + html_option = ''.join([ + 'Plugin: ']) + + @property + def select_del_instance(self): + name_inst = [] + db_plugins = DBPlugins(self.config) + name_inst_dict = db_plugins.get_instances() + for ns, inst_list in name_inst_dict.items(): + for inst in inst_list: + section = utils.instance_config_section(ns, inst) + if self.config.get(section) \ + and self.config[section].get('enabled'): + name_inst.append(''.join([ + ns, ':', inst])) + db_channels = DBChannels(self.config) + name_inst_list = db_channels.get_channel_instances() + self.update_ns_inst(name_inst, name_inst_list) + db_epg = DBepg(self.config) + name_inst_list = db_epg.get_epg_instances() + self.update_ns_inst(name_inst, name_inst_list) + db_sched = DBScheduler(self.config) + name_inst_list = db_sched.get_task_instances() + self.update_ns_inst(name_inst, name_inst_list) + + html_option = ''.join([ + 'Instance: ']) + + def update_ns_inst(self, _name_inst, _name_inst_list): + for name_inst_dict in _name_inst_list: + if name_inst_dict['instance'] is not None: + ns_in = ''.join([ + name_inst_dict['namespace'], + ':', + name_inst_dict['instance'], + ]) + if ns_in not in _name_inst: + _name_inst.append(ns_in) diff --git a/lib/db/db.py b/lib/db/db.py new file mode 100644 index 0000000000000000000000000000000000000000..4273e2d99a549ebea9ff7d12cbccdc658821c8ca --- /dev/null +++ b/lib/db/db.py @@ -0,0 +1,399 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import os +import pathlib +import random +import shutil +import sqlite3 +import threading +import time + +LOCK = threading.Lock() +DB_EXT = '.db' +BACKUP_EXT = '.sql' + +# trailers used in sqlcmds.py +SQL_CREATE_TABLES = 'ct' +SQL_DROP_TABLES = 'dt' +SQL_ADD_ROW = '_add' +SQL_UPDATE = '_update' +SQL_GET = '_get' +SQL_DELETE = '_del' +FILE_LINK_ZIP = '_filelinks' + +class DB: + conn = {} + + def __init__(self, _config, _db_name, _sqlcmds): + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) + self.config = _config + self.db_name = _db_name + self.sqlcmds = _sqlcmds + self.cur = None + self.offset = -1 + self.where = None + self.sqlcmd = None + self.db_fullpath = pathlib.Path(self.config['paths']['db_dir']) \ + .joinpath(_db_name + DB_EXT) + if not os.path.exists(self.db_fullpath): + self.logger.debug('Creating new database: {} {}'.format(_db_name, self.db_fullpath)) + self.create_tables() + self.check_connection() + DB.conn[self.db_name][threading.get_ident()].commit() + + def sql_exec(self, _sqlcmd, _bindings=None, _cursor=None): + try: + self.check_connection() + if _bindings: + if _cursor: + return _cursor.execute(_sqlcmd, _bindings) + else: + return DB.conn[self.db_name][threading.get_ident()].execute(_sqlcmd, _bindings) + else: + if _cursor: + return _cursor.execute(_sqlcmd) + else: + return DB.conn[self.db_name][threading.get_ident()].execute(_sqlcmd) + except sqlite3.IntegrityError as e: + DB.conn[self.db_name][threading.get_ident()].close() + del DB.conn[self.db_name][threading.get_ident()] + raise e + + def rnd_sleep(self, _sec): + r = random.randrange(0, 50) + sec = _sec + r / 100 + time.sleep(sec) + + def add(self, _table, _values): + self.logger.trace('DB add() called {}'.format(threading.get_ident())) + cur = None + sqlcmd = self.sqlcmds[''.join([_table, SQL_ADD_ROW])] + i = 10 + while i > 0: + i -= 1 + try: + self.check_connection() + cur = DB.conn[self.db_name][threading.get_ident()].cursor() + self.sql_exec(sqlcmd, _values, cur) + DB.conn[self.db_name][threading.get_ident()].commit() + lastrow = cur.lastrowid + cur.close() + self.logger.trace('DB add() exit {}'.format(threading.get_ident())) + return lastrow + except sqlite3.OperationalError as e: + self.logger.warning('{} Add request ignored, retrying {}, {}' + .format(self.db_name, i, e)) + DB.conn[self.db_name][threading.get_ident()].rollback() + if cur is not None: + cur.close() + self.rnd_sleep(0.3) + self.logger.trace('DB add() exit {}'.format(threading.get_ident())) + return None + + def delete(self, _table, _values): + self.logger.trace('DB delete() called {}'.format(threading.get_ident())) + cur = None + sqlcmd = self.sqlcmds[''.join([_table, SQL_DELETE])] + i = 10 + while i > 0: + i -= 1 + try: + self.check_connection() + cur = DB.conn[self.db_name][threading.get_ident()].cursor() + self.sql_exec(sqlcmd, _values, cur) + num_deleted = cur.rowcount + DB.conn[self.db_name][threading.get_ident()].commit() + cur.close() + self.logger.trace('DB delete() exit {}'.format(threading.get_ident())) + return num_deleted + except sqlite3.OperationalError as e: + self.logger.warning('{} Delete request ignored, retrying {}, {}' + .format(self.db_name, i, e)) + DB.conn[self.db_name][threading.get_ident()].rollback() + if cur is not None: + cur.close() + self.rnd_sleep(0.3) + self.logger.trace('DB delete() exit {}'.format(threading.get_ident())) + return 0 + + def update(self, _table, _values=None): + self.logger.trace('DB update() called {}'.format(threading.get_ident())) + cur = None + sqlcmd = self.sqlcmds[''.join([_table, SQL_UPDATE])] + i = 10 + while i > 0: + i -= 1 + try: + LOCK.acquire(True) + self.check_connection() + cur = DB.conn[self.db_name][threading.get_ident()].cursor() + self.sql_exec(sqlcmd, _values, cur) + DB.conn[self.db_name][threading.get_ident()].commit() + lastrow = cur.lastrowid + cur.close() + LOCK.release() + self.logger.trace('DB update() exit {}'.format(threading.get_ident())) + return lastrow + except sqlite3.OperationalError as e: + self.logger.notice('{} Update request ignored, retrying {}, {}' + .format(self.db_name, i, e)) + DB.conn[self.db_name][threading.get_ident()].rollback() + if cur is not None: + cur.close() + LOCK.release() + self.rnd_sleep(0.3) + self.logger.trace('DB update() exit {}'.format(threading.get_ident())) + return None + + def commit(self): + DB.conn[self.db_name][threading.get_ident()].commit() + + def get(self, _table, _where=None): + cur = None + sqlcmd = self.sqlcmds[''.join([_table, SQL_GET])] + i = 10 + while i > 0: + i -= 1 + try: + self.check_connection() + cur = DB.conn[self.db_name][threading.get_ident()].cursor() + self.sql_exec(sqlcmd, _where, cur) + result = cur.fetchall() + cur.close() + return result + except sqlite3.OperationalError as e: + self.logger.warning('{} GET request ignored retrying {}, {}' + .format(self.db_name, i, e)) + DB.conn[self.db_name][threading.get_ident()].rollback() + if cur is not None: + cur.close() + self.rnd_sleep(0.3) + return None + + def get_dict(self, _table, _where=None, sql=None): + cur = None + if sql is None: + sqlcmd = self.sqlcmds[''.join([_table, SQL_GET])] + else: + sqlcmd = sql + i = 10 + while i > 0: + i -= 1 + try: + LOCK.acquire(True) + self.check_connection() + cur = DB.conn[self.db_name][threading.get_ident()].cursor() + self.sql_exec(sqlcmd, _where, cur) + records = cur.fetchall() + rows = [] + for row in records: + rows.append(dict(zip([c[0] for c in cur.description], row))) + cur.close() + LOCK.release() + return rows + except sqlite3.OperationalError as e: + self.logger.warning('{} GET request ignored retrying {}, {}' + .format(self.db_name, i, e)) + DB.conn[self.db_name][threading.get_ident()].rollback() + if cur is not None: + cur.close() + LOCK.release() + self.rnd_sleep(0.3) + return None + + def get_init(self, _table, _where=None): + """ + Requires "LIMIT ? OFFSET ?" at the end of the sql statement + """ + self.sqlcmd = self.sqlcmds[''.join([_table, SQL_GET])] + self.where = list(_where) + self.offset = 0 + + def get_dict_next(self): + w_list = self.where.copy() + w_list.extend((1, self.offset)) + self.cur = self.sql_exec(self.sqlcmd, tuple(w_list)) + records = self.cur.fetchall() + self.offset += 1 + if len(records) == 0: + return None + row = records[0] + return dict(zip([c[0] for c in self.cur.description], row)) + + def save_file(self, _keys, _blob): + """ + Stores the blob in the folder with the db name with + the filename of concatenated _keys + _keys is the list of unique keys for the table + Returns the filepath to the file generated + """ + folder_path = pathlib.Path(self.config['paths']['db_dir']) \ + .joinpath(self.db_name) + os.makedirs(folder_path, exist_ok=True) + filename = '_'.join(str(x) for x in _keys) + '.txt' + file_rel_path = pathlib.Path(self.db_name).joinpath(filename) + filepath = folder_path.joinpath(filename) + try: + with open(filepath, mode='wb') as f: + if isinstance(_blob, str): + f.write(_blob.encode()) + else: + f.write(_blob) + f.flush() + f.close() + except PermissionError as ex: + self.logger.warning('Unable to create linked database file {}' + .format(file_rel_path)) + return None + return file_rel_path + + def delete_file(self, _filepath): + """ + _filepath is relative to the database path + """ + fullpath = pathlib.Path(self.config['paths']['db_dir']) \ + .joinpath(_filepath) + try: + os.remove(fullpath) + return True + except PermissionError as ex: + self.logger.warning('Unable to delete linked database file {}' + .format(_filepath)) + return False + except FileNotFoundError as ex: + self.logger.warning('File missing, unable to delete linked database file {}' + .format(_filepath)) + return False + + def get_file(self, _filepath): + """ + _filepath is relative to the database path + return the blob + """ + fullpath = pathlib.Path(self.config['paths']['db_dir']) \ + .joinpath(_filepath) + + if not fullpath.exists(): + self.logger.warning('Linked database file Missing {}'.format(_filepath)) + return None + try: + with open(fullpath, mode='rb') as f: + blob = f.read() + f.close() + return blob + except PermissionError as ex: + self.logger.warning('Unable to read linked database file {}' + .format(_filepath)) + return None + + def get_file_by_key(self, _keys): + filename = '_'.join(str(x) for x in _keys) + '.txt' + file_rel_path = pathlib.Path(self.db_name).joinpath(filename) + return self.get_file(file_rel_path) + + def reinitialize_tables(self): + self.drop_tables() + self.create_tables() + + def create_tables(self): + for table in self.sqlcmds[''.join([SQL_CREATE_TABLES])]: + cur = self.sql_exec(table) + DB.conn[self.db_name][threading.get_ident()].commit() + + def drop_tables(self): + for table in self.sqlcmds[SQL_DROP_TABLES]: + cur = self.sql_exec(table) + DB.conn[self.db_name][threading.get_ident()].commit() + + def export_sql(self, backup_folder): + self.logger.debug('Running backup for {} database'.format(self.db_name)) + try: + if not os.path.isdir(backup_folder): + os.mkdir(backup_folder) + self.check_connection() + + # Check for linked file folder and zip up if present + db_linkfilepath = pathlib.Path(self.config['paths']['db_dir']) \ + .joinpath(self.db_name) + if db_linkfilepath.exists(): + self.logger.debug('Linked file folder exists, backing up folder for db {}'.format(self.db_name)) + backup_filelink = pathlib.Path(backup_folder, self.db_name + FILE_LINK_ZIP) + shutil.make_archive(backup_filelink, 'zip', db_linkfilepath) + + backup_file = pathlib.Path(backup_folder, self.db_name + BACKUP_EXT) + with open(backup_file, 'w', encoding='utf-8') as export_f: + for line in DB.conn[self.db_name][threading.get_ident()].iterdump(): + export_f.write('%s\n' % line) + except PermissionError as e: + self.logger.warning(e) + self.logger.warning('Unable to make backups') + + def import_sql(self, backup_folder): + self.logger.debug('Running restore for {} database'.format(self.db_name)) + if not os.path.isdir(backup_folder): + msg = 'Backup folder does not exist: {}'.format(backup_folder) + self.logger.warning(msg) + return msg + + # Check for linked file folder and zip up if present + backup_filelink = pathlib.Path(backup_folder, self.db_name + FILE_LINK_ZIP + '.zip') + db_linkfilepath = pathlib.Path(self.config['paths']['db_dir']) \ + .joinpath(self.db_name) + if backup_filelink.exists(): + self.logger.debug('Linked file folder exists, restoring folder for db {}'.format(self.db_name)) + shutil.unpack_archive(backup_filelink, db_linkfilepath) + + backup_file = pathlib.Path(backup_folder, self.db_name + BACKUP_EXT) + if not os.path.isfile(backup_file): + msg = 'Backup file does not exist, skipping: {}'.format(backup_file) + self.logger.info(msg) + return msg + self.check_connection() + self.drop_tables() + with open(backup_file, 'r') as import_f: + cmd = '' + for line in import_f: + cmd += line + if ';' in line[-3:]: + DB.conn[self.db_name][threading.get_ident()].execute(cmd) + cmd = '' + return None + + def close(self): + thread_id = threading.get_ident() + DB.conn[self.db_name][thread_id].close() + del DB.conn[self.db_name][thread_id] + self.logger.debug('{} database closed for thread:{}'.format(self.db_name, thread_id)) + + def check_connection(self): + if self.db_name not in DB.conn: + DB.conn[self.db_name] = {} + db_conn_dbname = DB.conn[self.db_name] + + if threading.get_ident() not in db_conn_dbname: + db_conn_dbname[threading.get_ident()] = sqlite3.connect( + self.db_fullpath, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES) + else: + try: + db_conn_dbname[threading.get_ident()].total_changes + except sqlite3.ProgrammingError: + self.logger.debug('Reopening {} database for thread:{}'.format(self.db_name, threading.get_ident())) + db_conn_dbname[threading.get_ident()] = sqlite3.connect( + self.db_fullpath, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES) diff --git a/lib/db/db_channels.py b/lib/db/db_channels.py new file mode 100644 index 0000000000000000000000000000000000000000..29b9f76409ff02e49489f4f9635d9cdac0b40ffc --- /dev/null +++ b/lib/db/db_channels.py @@ -0,0 +1,458 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import ast +import json +import datetime +import sqlite3 +import threading + +from lib.db.db import DB +from lib.common.decorators import Backup +from lib.common.decorators import Restore + + +DB_CHANNELS_TABLE = 'channels' +DB_STATUS_TABLE = 'status' +DB_ZONE_TABLE = 'zones' +DB_CATEGORIES_TABLE = 'categories' +DB_CONFIG_NAME = 'db_files-channels_db' + +sqlcmds = { + 'ct': [ + """ + CREATE TABLE IF NOT EXISTS channels ( + namespace VARCHAR(255) NOT NULL, + instance VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL, + uid VARCHAR(255) NOT NULL, + number VARCHAR(255) NOT NULL, + display_number VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + group_tag VARCHAR(255), + updated BOOLEAN NOT NULL, + thumbnail VARCHAR(255), + thumbnail_size VARCHAR(255), + atsc VARCHAR(1500), + json TEXT NOT NULL, + UNIQUE(namespace, instance, uid) + ) + """, + """ + CREATE TABLE IF NOT EXISTS status ( + namespace VARCHAR(255) NOT NULL, + instance VARCHAR(255), + last_update TIMESTAMP, + UNIQUE(namespace, instance) + ) + """, + """ + CREATE TABLE IF NOT EXISTS categories ( + namespace VARCHAR(255) NOT NULL, + instance VARCHAR(255), + uid VARCHAR(255) NOT NULL, + category VARCHAR(255) NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS zones ( + namespace VARCHAR(255) NOT NULL, + instance VARCHAR(255), + uid VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + UNIQUE(namespace, instance, uid) + ) + """ + + ], + 'dt': [ + """ + DROP TABLE IF EXISTS channels + """, + """ + DROP TABLE IF EXISTS status + """, + """ + DROP TABLE IF EXISTS categories + """, + """ + DROP TABLE IF EXISTS zones + """ + ], + + 'channels_add': + """ + INSERT INTO channels ( + namespace, instance, enabled, uid, number, display_number, display_name, + group_tag, thumbnail, thumbnail_size, updated, json + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) + """, + 'channels_update': + """ + UPDATE channels SET + number=?, updated=?, json=? + WHERE namespace=? AND instance=? AND uid=? + """, + 'channels_editable_update': + """ + UPDATE channels SET + enabled=?, display_number=?, display_name=?, group_tag=?, thumbnail=?, thumbnail_size=? + WHERE namespace=? AND instance=? AND uid=? + """, + 'channels_updated_update': + """ + UPDATE channels SET updated = False WHERE namespace=? AND instance=? + """, + 'channels_atsc_update': + """ + UPDATE channels SET + atsc=? + WHERE namespace=? AND instance=? AND uid=? + """, + 'channels_json_update': + """ + UPDATE channels SET + json=? + WHERE namespace=? AND instance=? AND uid=? + """, + 'channels_chnum_update': + """ + UPDATE channels SET + display_number=? + WHERE namespace=? AND instance=? AND uid=? + """, + 'channels_num_update': + """ + UPDATE channels SET + number=? + WHERE namespace=? AND instance=? AND uid=? + """, + 'channels_del': + """ + DELETE FROM channels WHERE updated LIKE ? + AND namespace LIKE ? AND instance LIKE ? + """, + 'channels_get': + """ + SELECT * FROM channels WHERE namespace LIKE ? + AND instance LIKE ? AND enabled LIKE ? + ORDER BY CAST(number as FLOAT), namespace, instance + """, + 'channels_one_get': + """ + SELECT * FROM channels WHERE uid=? AND namespace LIKE ? + AND instance LIKE ? + """, + 'channels_name_get': + """ + SELECT DISTINCT namespace FROM channels + """, + 'channels_instance_get': + """ + SELECT DISTINCT namespace,instance FROM channels + """, + + 'status_add': + """ + INSERT OR REPLACE INTO status ( + namespace, instance, last_update + ) VALUES ( ?, ?, ? ) + """, + 'status_get': + """ + SELECT datetime(last_update, 'localtime') FROM status WHERE + namespace=? AND instance=? + """, + 'status_del': + """ + DELETE FROM status WHERE + namespace LIKE ? AND instance LIKE ? + """, + 'zones_add': + """ + INSERT OR REPLACE INTO zones ( + namespace, instance, uid, name + ) VALUES ( ?, ?, ?, ? ) + """, + 'zones_get': + """ + SELECT uid, name FROM zones WHERE + namespace=? AND instance=? + """ + +} + + +class DBChannels(DB): + + def __init__(self, _config): + super().__init__(_config, _config['datamgmt'][DB_CONFIG_NAME], sqlcmds) + + def save_channel_list(self, _namespace, _instance, _ch_dict, save_edit_groups=True): + """ + Assume the list is complete and will remove any old channels not updated + """ + if _instance is None or _namespace is None: + self.logger.warning( + 'Saving Channel List: Namespace or Instance is None {}:{}' + .format(_namespace, _instance)) + self.update(DB_CHANNELS_TABLE + '_updated', (_namespace, _instance,)) + for ch in _ch_dict: + if save_edit_groups: + edit_groups = ch['groups_other'] + else: + edit_groups = None + try: + self.add(DB_CHANNELS_TABLE, ( + _namespace, + _instance, + ch['enabled'], + ch['id'], + ch['number'], + ch['number'], + ch['name'], + edit_groups, + ch['thumbnail'], + str(ch['thumbnail_size']), + True, + json.dumps(ch))) + except sqlite3.IntegrityError as ex: + # record already present. Check the editable fields and update as needed + ch_stored = self.get_channel(ch['id'], _namespace, _instance) + + ch_stored['enabled'] = ch['enabled'] + ch_stored['display_name'] = ch['display_name'] + ch_stored['group_tag'] = ch['groups_other'] + ch_stored['thumbnail'] = ch['thumbnail'] + ch_stored['thumbnail_size'] = ch['thumbnail_size'] + self.update_channel(ch_stored) + self.update(DB_CHANNELS_TABLE, ( + ch['number'], + True, + json.dumps(ch), + _namespace, + _instance, + ch['id'] + )) + except sqlite3.InterfaceError as ex: + self.logger.warning('InterfaceError: Bind data: {} : {} : ({}){} : {} : {} : {}'.format( \ + ch['id'], ch['number'], type(ch['name']), ch['name'], edit_groups, \ + ch['thumbnail'], str(ch['thumbnail_size']) )) + raise ex + + self.add(DB_STATUS_TABLE, ( + _namespace, _instance, datetime.datetime.now())) + + self.delete(DB_CHANNELS_TABLE, (False, _namespace, _instance,)) + + def update_channel(self, _ch): + """ + Updates the editable fields for one channel + """ + self.update(DB_CHANNELS_TABLE + '_editable', ( + _ch['enabled'], + _ch['display_number'], + _ch['display_name'], + _ch['group_tag'], + _ch['thumbnail'], + str(_ch['thumbnail_size']), + _ch['namespace'], + _ch['instance'], + _ch['uid'] + )) + + def del_channels(self, _namespace, _instance): + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + return self.delete(DB_CHANNELS_TABLE, ('%', _namespace, _instance,)) + + def del_status(self, _namespace=None, _instance=None): + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + return self.delete(DB_STATUS_TABLE, (_namespace, _instance,)) + + def get_status(self, _namespace, _instance): + result = self.get(DB_STATUS_TABLE, (_namespace, _instance)) + if result: + last_update = result[0][0] + if last_update is not None: + return datetime.datetime.fromisoformat(last_update) + else: + return None + else: + return None + + def get_channels(self, _namespace, _instance, _enabled=None): + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + if _enabled is None: + _enabled = '%' + + rows_dict = {} + rows = self.get_dict(DB_CHANNELS_TABLE, (_namespace, _instance, _enabled)) + if rows is None: + return None + for row in rows: + ch = json.loads(row['json']) + row['json'] = ch + row['thumbnail_size'] = ast.literal_eval(row['thumbnail_size']) + if row['atsc'] is not None: + row['atsc'] = ast.literal_eval(row['atsc']) + # handles the uid multiple times across instances + if row['uid'] in rows_dict.keys(): + rows_dict[row['uid']].append(row) + else: + rows_dict[row['uid']] = [] + rows_dict[row['uid']].append(row) + + return rows_dict + + def get_channel_names(self): + return self.get_dict(DB_CHANNELS_TABLE + '_name') + + def get_channel_instances(self): + return self.get_dict(DB_CHANNELS_TABLE + '_instance') + + def get_channel(self, _uid, _namespace, _instance): + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + + rows = self.get_dict(DB_CHANNELS_TABLE + '_one', (_uid, _namespace, _instance,)) + if rows: + for row in rows: + ch = json.loads(row['json']) + row['json'] = ch + if row['atsc'] is not None: + row['atsc'] = ast.literal_eval(row['atsc']) + return row + return None + + def update_channel_atsc(self, _ch): + """ + Updates the atsc field for one channel + """ + atsc_str = str(_ch['atsc']) + self.update(DB_CHANNELS_TABLE + '_atsc', ( + atsc_str, + _ch['namespace'], + _ch['instance'], + _ch['uid'] + )) + + def update_channel_json(self, _ch, _namespace, _instance): + """ + Updates the json field for one channel + """ + json_str = json.dumps(_ch) + self.update(DB_CHANNELS_TABLE + '_json', ( + json_str, + _namespace, + _instance, + _ch['id'] + )) + + def update_channel_number(self, _ch): + """ + Updates the display_number field for one channel + """ + display_number = str(_ch['display_number']) + self.update(DB_CHANNELS_TABLE + '_chnum', ( + display_number, + _ch['namespace'], + _ch['instance'], + _ch['uid'] + )) + + def update_number(self, _ch): + """ + Updates the display_number field for one channel + """ + number = str(_ch['number']) + self.update(DB_CHANNELS_TABLE + '_num', ( + number, + _ch['namespace'], + _ch['instance'], + _ch['uid'] + )) + + def get_sorted_channels(self, _namespace, _instance, + _first_sort_key=[None, True], _second_sort_key=[None, True]): + """ + Using dynamic SQl to create a SELECT statement and send to the DB + keys are [name_of_column, direction_asc=True] + """ + where = ' WHERE namespace LIKE ? AND instance LIKE ? ' + orderby_front = ' ORDER BY ' + orderby_end = ' CAST(number as FLOAT), namespace, instance ' + orderby1 = self.get_channels_orderby(_first_sort_key[0], _first_sort_key[1]) + orderby2 = self.get_channels_orderby(_second_sort_key[0], _second_sort_key[1]) + sqlcmd = ''.join(['SELECT * FROM channels ', where, orderby_front, orderby1, orderby2, orderby_end]) + + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + rows = self.get_dict(None, (_namespace, _instance,), sql=sqlcmd) + for row in rows: + ch = json.loads(row['json']) + row['json'] = ch + row['thumbnail_size'] = ast.literal_eval(row['thumbnail_size']) + return rows + + def get_channels_orderby(self, _column, _ascending): + str_types = ['namespace', 'instance', 'enabled', 'display_name', 'group_tag', 'thumbnail'] + float_types = ['uid', 'display_number'] + json_types = ['HD', 'callsign'] + if _ascending: + dir_ = 'ASC' + else: + dir_ = 'DESC' + if _column is None: + return '' + elif _column in str_types: + return ''.join([_column, ' ', dir_, ', ']) + elif _column in float_types: + return ''.join(['CAST(', _column, ' as FLOAT) ', dir_, ', ']) + elif _column in json_types: + return ''.join(['JSON_EXTRACT(json, "$.', _column, '") ', dir_, ', ']) + + def add_zone(self, _namespace, _instance, _uid, _name): + self.add(DB_ZONE_TABLE, (_namespace, _instance, _uid, _name)) + + def get_zones(self, _namespace, _instance): + return self.get_dict(DB_ZONE_TABLE, (_namespace, _instance,)) + + @Backup(DB_CONFIG_NAME) + def backup(self, backup_folder): + self.export_sql(backup_folder) + + @Restore(DB_CONFIG_NAME) + def restore(self, backup_folder): + msg = self.import_sql(backup_folder) + if msg is None: + return 'Channels Database Restored' + else: + return msg diff --git a/lib/db/db_config_defn.py b/lib/db/db_config_defn.py new file mode 100644 index 0000000000000000000000000000000000000000..5f2d6d6ab087802183ff15aeb56733b34c55eb60 --- /dev/null +++ b/lib/db/db_config_defn.py @@ -0,0 +1,254 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import json + +from lib.db.db import DB +from lib.common.decorators import Backup +from lib.common.decorators import Restore + + +DB_AREA_TABLE = 'area' +DB_SECTION_TABLE = 'section' +DB_INSTANCE_TABLE = 'instance' +DB_CONFIG_TABLE = 'config' +DB_CONFIG_NAME = 'db_files-defn_db' + +sqlcmds = { + 'ct': [ + """ + CREATE TABLE IF NOT EXISTS area ( + name VARCHAR(255) NOT NULL, + icon VARCHAR(255) NOT NULL, + label VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + PRIMARY KEY(name) + ) + """, + """ + CREATE TABLE IF NOT EXISTS section ( + area VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + sort VARCHAR(255) NOT NULL, + icon VARCHAR(255) NOT NULL, + label VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + settings TEXT NOT NULL, + FOREIGN KEY(area) REFERENCES area(name), + UNIQUE(area, name) + ) + """, + """ + CREATE TABLE IF NOT EXISTS config ( + key VARCHAR(255) NOT NULL, + settings TEXT NOT NULL, + PRIMARY KEY(key) + ) + """, + """ + CREATE TABLE IF NOT EXISTS instance ( + area VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + icon VARCHAR(255) NOT NULL, + label VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + settings TEXT NOT NULL, + FOREIGN KEY(area) REFERENCES area(name), + UNIQUE(area, name) + ) + """ + ], + + 'dt': [ + """ + DROP TABLE IF EXISTS area + """, + """ + DROP TABLE IF EXISTS section + """, + """ + DROP TABLE IF EXISTS config + """, + """ + DROP TABLE IF EXISTS instance + """ + ], + + 'area_add': + """ + INSERT OR REPLACE INTO area ( + name, icon, label, description + ) VALUES ( ?, ?, ?, ? ) + """, + 'area_get': + """ + SELECT * from area WHERE name LIKE ? ORDER BY rowid + """, + 'area_keys_get': + """ + SELECT name from area ORDER BY rowid + """, + + 'section_add': + """ + INSERT OR REPLACE INTO section ( + area, name, sort, icon, label, description, settings + ) VALUES ( ?, ?, ?, ?, ?, ?, ? ) + """, + 'section_get': + """ + SELECT * from section WHERE area = ? ORDER BY sort + """, + 'section_one_get': + """ + SELECT * from section WHERE area = ? AND name = ? ORDER BY sort + """, + 'section_name_get': + """ + SELECT area from section WHERE name = ? ORDER BY sort + """, + + 'instance_add': + """ + INSERT OR REPLACE INTO instance ( + area, name, icon, label, description, settings + ) VALUES ( ?, ?, ?, ?, ?, ? ) + """, + 'instance_get': + """ + SELECT * from instance WHERE area = ? ORDER BY rowid + """, + + 'config_add': + """ + INSERT OR REPLACE INTO config ( + key, settings + ) VALUES ( 'main', ? ) + """, + 'config_get': + """ + SELECT settings from config + """ + +} + + +class DBConfigDefn(DB): + + def __init__(self, _config): + super().__init__(_config, _config['datamgmt'][DB_CONFIG_NAME], sqlcmds) + + def get_area_dict(self, _where=None): + if not _where: + _where = '%' + return self.get_dict(DB_AREA_TABLE, (_where,)) + + def get_area_json(self, _where=None): + if not _where: + _where = '%' + return json.dumps(self.get_dict(DB_AREA_TABLE, (_where,))) + + def get_areas(self): + """ returns an array of the area names in id order + """ + area_tuple = self.get('area_keys') + areas = [area[0] for area in area_tuple] + return areas + + def add_area(self, _area, _area_data): + self.add(DB_AREA_TABLE, ( + _area, + _area_data['icon'], + _area_data['label'], + _area_data['description'] + )) + + def get_sections_dict(self, _where): + rows_dict = {} + rows = self.get_dict(DB_SECTION_TABLE, (_where,)) + for row in rows: + settings = json.loads(row['settings']) + row['settings'] = settings + rows_dict[row['name']] = row + return rows_dict + + def get_one_section_dict(self, _area, _section): + rows_dict = {} + rows = self.get_dict(DB_SECTION_TABLE+'_one', (_area, _section,)) + for row in rows: + settings = json.loads(row['settings']) + row['settings'] = settings + rows_dict[row['name']] = row + return rows_dict + + def get_area_by_section(self, _where): + """ returns an array of the area names that match the section + """ + area_tuple = self.get('section_name', (_where,)) + areas = [area[0] for area in area_tuple] + return areas + + def add_section(self, _area, _section, _section_data): + self.add(DB_SECTION_TABLE, ( + _area, + _section, + _section_data['sort'], + _section_data['icon'], + _section_data['label'], + _section_data['description'], + json.dumps(_section_data['settings']) + )) + + def get_instance_dict(self, _where): + rows_dict = {} + rows = self.get_dict(DB_INSTANCE_TABLE, (_where,)) + for row in rows: + settings = json.loads(row['settings']) + row['settings'] = settings + rows_dict[row['name']] = row + return rows_dict + + def add_instance(self, _area, _section, _section_data): + self.add(DB_INSTANCE_TABLE, ( + _area, + _section, + _section_data['icon'], + _section_data['label'], + _section_data['description'], + json.dumps(_section_data['settings']) + )) + + def add_config(self, _config): + self.add(DB_CONFIG_TABLE, ( + json.dumps(_config), + )) + + def get_config(self): + return json.loads(self.get_dict(DB_CONFIG_TABLE)[0]['settings']) + + @Backup(DB_CONFIG_NAME) + def backup(self, backup_folder): + self.export_sql(backup_folder) + + @Restore(DB_CONFIG_NAME) + def restore(self, backup_folder): + msg = self.import_sql(backup_folder) + if msg is None: + return 'Config Database Restored' + else: + return msg diff --git a/lib/db/db_epg.py b/lib/db/db_epg.py new file mode 100644 index 0000000000000000000000000000000000000000..c5367663a0702871c0955434df80caff58bfbcd9 --- /dev/null +++ b/lib/db/db_epg.py @@ -0,0 +1,229 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import json +import datetime + +from lib.db.db import DB +from lib.common.decorators import Backup +from lib.common.decorators import Restore + +DB_EPG_TABLE = 'epg' +DB_CONFIG_NAME = 'db_files-epg_db' + +sqlcmds = { + 'ct': [ + """ + CREATE TABLE IF NOT EXISTS epg ( + namespace VARCHAR(255) NOT NULL, + instance VARCHAR(255) NOT NULL, + day DATE NOT NULL, + last_update TIMESTAMP, + file VARCHAR(255) NOT NULL, + UNIQUE(namespace, instance, day) + ) + """ + ], + 'dt': [ + """ + DROP TABLE IF EXISTS epg + """ + ], + + 'epg_column_names_get': + """ + SELECT name FROM pragma_table_info('epg') + """, + + 'epg_add': + """ + INSERT OR REPLACE INTO epg ( + namespace, instance, day, last_update, file + ) VALUES ( ?, ?, ?, ?, ? ) + """, + + 'epg_by_day_del': + """ + DELETE FROM epg WHERE namespace LIKE ? AND instance LIKE ? AND day < DATE('now',?) + """, + 'epg_by_day_get': + """ + SELECT file FROM epg WHERE namespace LIKE ? AND instance LIKE ? AND day < DATE('now',?) + """, + + 'epg_instance_del': + """ + DELETE FROM epg WHERE namespace=? AND instance LIKE ? + """, + + 'epg_instance_get': + """ + SELECT file FROM epg WHERE namespace=? AND instance LIKE ? + """, + + 'epg_last_update_get': + """ + SELECT datetime(last_update, 'localtime') FROM epg WHERE + namespace=? AND instance LIKE ? and day=? + """, + + 'epg_last_update_update': + """ + UPDATE epg SET + last_update=? WHERE namespace LIKE ? AND instance LIKE ? + """, + + 'epg_get': + """ + SELECT * FROM epg WHERE + namespace LIKE ? AND instance LIKE ? ORDER BY day LIMIT ? OFFSET ? + """, + 'epg_one_get': + """ + SELECT * FROM epg WHERE + namespace=? AND instance=? AND day=? + """, + 'epg_name_get': + """ + SELECT DISTINCT namespace FROM epg + """, + 'epg_instances_get': + """ + SELECT DISTINCT namespace, instance FROM epg + """ +} + + +class DBepg(DB): + + def __init__(self, _config): + super().__init__(_config, _config['datamgmt'][DB_CONFIG_NAME], sqlcmds) + + def get_col_names(self): + return self.get(DB_EPG_TABLE + '_column_names') + + def save_program_list(self, _namespace, _instance, _day, _prog_list): + filepath = self.save_file((DB_EPG_TABLE, _namespace, _instance, _day), json.dumps(_prog_list)) + if filepath: + self.add(DB_EPG_TABLE, ( + _namespace, + _instance, + _day, + datetime.datetime.utcnow(), + str(filepath),)) + + def del_old_programs(self, _namespace, _instance, _days='-2 day'): + """ + Removes all records for this namespace/instance that are over 2 day old + """ + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + files = self.get(DB_EPG_TABLE + '_by_day', (_namespace, _instance, _days,)) + files = [x[0] for x in files] + for f in files: + self.delete_file(f) + self.delete(DB_EPG_TABLE + '_by_day', (_namespace, _instance, _days,)) + + def del_instance(self, _namespace, _instance): + """ + Removes all records for this namespace/instance + """ + if not _instance: + _instance = '%' + files = self.get(DB_EPG_TABLE + '_instance', (_namespace, _instance,)) + files = [x[0] for x in files] + for f in files: + self.delete_file(f) + return self.delete(DB_EPG_TABLE + '_instance', (_namespace, _instance,)) + + def set_last_update(self, _namespace=None, _instance=None, _day=None): + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + self.update(DB_EPG_TABLE + '_last_update', ( + _day, + _namespace, + _instance, + )) + + def get_last_update(self, _namespace, _instance, _day): + if not _instance: + _instance = '%' + result = self.get(DB_EPG_TABLE + '_last_update', (_namespace, _instance, _day,)) + if result is None or len(result) == 0: + return None + else: + last_update = result[0][0] + if last_update is not None: + return datetime.datetime.fromisoformat(last_update) + else: + return None + + def get_epg_names(self): + return self.get_dict(DB_EPG_TABLE + '_name') + + def get_epg_instances(self): + return self.get_dict(DB_EPG_TABLE + '_instances') + + def get_epg_one(self, _namespace, _instance, _day): + row = self.get_dict(DB_EPG_TABLE + '_one', (_namespace, _instance, _day)) + if len(row): + blob = self.get_file_by_key((_namespace, _instance, _day,)) + if blob: + row[0]['json'] = json.loads(blob) + return row + return [] + + def init_get_query(self, _namespace, _instance): + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + self.get_init(DB_EPG_TABLE, (_namespace, _instance,)) + + def get_next_row(self): + row = self.get_dict_next() + namespace = None + instance = None + day = None + if row: + namespace = row['namespace'] + instance = row['instance'] + day = row['day'] + file = row['file'] + blob = self.get_file(file) + if blob: + json_data = json.loads(blob) + else: + json_data = [] + row = json_data + return row, namespace, instance, day + + def close_query(self): + self.cur.close() + + @Backup(DB_CONFIG_NAME) + def backup(self, backup_folder): + self.export_sql(backup_folder) + + @Restore(DB_CONFIG_NAME) + def restore(self, backup_folder): + return self.import_sql(backup_folder) diff --git a/lib/db/db_epg_programs.py b/lib/db/db_epg_programs.py new file mode 100644 index 0000000000000000000000000000000000000000..98af69cd9553e8e11dafe7484fee4d83332b55ff --- /dev/null +++ b/lib/db/db_epg_programs.py @@ -0,0 +1,110 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import json +import datetime + +from lib.db.db import DB +from lib.common.decorators import Backup +from lib.common.decorators import Restore + +DB_PROGRAMS_TABLE = 'programs' +DB_CONFIG_NAME = 'db_files-epg_programs_db' + +sqlcmds = { + 'ct': [ + """ + CREATE TABLE IF NOT EXISTS programs ( + namespace VARCHAR(255) NOT NULL, + id varchar(255) NOT NULL, + last_update TIMESTAMP, + json TEXT NOT NULL, + UNIQUE(namespace, id) + ) + """ + ], + 'dt': [ + """ + DROP TABLE IF EXISTS programs + """, + ], + + 'programs_add': + """ + INSERT OR REPLACE INTO programs ( + namespace, id, last_update, json + ) VALUES ( ?, ?, ?, ? ) + """, + + 'programs_by_day_del': + """ + DELETE FROM programs WHERE namespace=? AND last_update < DATE('now',?) + """, + 'programs_del': + """ + DELETE FROM programs WHERE namespace=? + """, + 'programs_get': + """ + SELECT * FROM programs WHERE + namespace=? AND id=? + """, + 'programs_name_get': + """ + SELECT DISTINCT namespace FROM programs + """, +} + + +class DBEpgPrograms(DB): + + def __init__(self, _config): + super().__init__(_config, _config['datamgmt'][DB_CONFIG_NAME], sqlcmds) + + def save_program(self, _namespace, _id, _prog_dict): + self.add(DB_PROGRAMS_TABLE, ( + _namespace, + _id, + datetime.datetime.utcnow(), + json.dumps(_prog_dict),)) + + def del_old_programs(self, _namespace, _instance, _days='-30 day'): + """ + Removes all records for this namespace/instance that are over xx days old + """ + self.delete(DB_PROGRAMS_TABLE + '_by_day', (_namespace, _days)) + + def del_namespace(self, _namespace): + """ + Removes all records for this namespace + """ + self.delete(DB_PROGRAMS_TABLE, (_namespace,)) + + def get_program_names(self): + return self.get_dict(DB_PROGRAMS_TABLE + '_name') + + def get_program(self, _namespace, _id): + return self.get_dict(DB_PROGRAMS_TABLE, (_namespace, _id)) + + @Backup(DB_CONFIG_NAME) + def backup(self, backup_folder): + self.export_sql(backup_folder) + + @Restore(DB_CONFIG_NAME) + def restore(self, backup_folder): + return self.import_sql(backup_folder) diff --git a/lib/db/db_plugins.py b/lib/db/db_plugins.py new file mode 100644 index 0000000000000000000000000000000000000000..4de829131e7f5a5da93409e4d53ae89f018fe824 --- /dev/null +++ b/lib/db/db_plugins.py @@ -0,0 +1,272 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import json + +from lib.db.db import DB +from lib.common.decorators import Backup +from lib.common.decorators import Restore + +DB_REPOS_TABLE = 'repos' +DB_PLUGINS_TABLE = 'plugins' +DB_INSTANCE_TABLE = 'instance' +DB_CONFIG_NAME = 'db_files-plugins_db' + +sqlcmds = { + 'ct': [ + """ + CREATE TABLE IF NOT EXISTS repos ( + id VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + url VARCHAR(255) NOT NULL, + json TEXT NOT NULL, + UNIQUE(id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS plugins ( + id VARCHAR(255) NOT NULL, + repo VARCHAR(255) NOT NULL, + namespace VARCHAR(255) NOT NULL, + installed BOOLEAN NOT NULL, + json TEXT NOT NULL, + UNIQUE(repo, namespace, id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS instance ( + repo VARCHAR(255) NOT NULL, + namespace VARCHAR(255) NOT NULL, + instance VARCHAR(255) NOT NULL, + description TEXT, + UNIQUE(repo, namespace, instance) + ) + """ + ], + 'dt': [ + """ + DROP TABLE IF EXISTS instance + """, + """ + DROP TABLE IF EXISTS plugins + """, + """ + DROP TABLE IF EXISTS repos + """ + ], + 'repos_add': + """ + INSERT OR REPLACE INTO repos ( + id, name, url, json + ) VALUES ( ?, ?, ?, ? ) + """, + 'repos_get': + """ + SELECT * FROM repos WHERE id LIKE ? + """, + 'repos_del': + """ + DELETE FROM repos WHERE id=? + """, + + 'plugins_add': + """ + INSERT OR REPLACE INTO plugins ( + id, repo, namespace, installed, json + ) VALUES ( ?, ?, ?, ?, ? ) + """, + 'plugins_get': + """ + SELECT * FROM plugins WHERE repo LIKE ? AND id LIKE ? + AND installed=? + """, + 'plugins_name_get': + """ + SELECT * FROM plugins WHERE repo LIKE ? AND namespace LIKE ? + AND installed=? + """, + 'plugins_all_get': + """ + SELECT * FROM plugins WHERE repo LIKE ? AND id LIKE ? + """, + 'plugins_all_name_get': + """ + SELECT * FROM plugins WHERE repo LIKE ? AND namespace LIKE ? + """, + 'plugins_del': + """ + DELETE FROM plugins WHERE repo=? AND id=? + """, + + 'instance_add': + """ + INSERT OR REPLACE INTO instance ( + repo, namespace, instance, description + ) VALUES ( ?, ?, ?, ? ) + """, + 'instance_get': + """ + SELECT * FROM instance WHERE repo LIKE ? + AND namespace LIKE ? ORDER BY namespace, instance + """, + 'instance_del': + """ + DELETE FROM instance WHERE repo LIKE ? AND + namespace LIKE ? AND instance LIKE ? + """ +} + + +class DBPlugins(DB): + + def __init__(self, _config): + super().__init__(_config, _config['datamgmt'][DB_CONFIG_NAME], sqlcmds) + + def save_repo(self, _repo_dict): + self.add(DB_REPOS_TABLE, ( + _repo_dict['id'], + _repo_dict['name'], + _repo_dict['repo_url'], + json.dumps(_repo_dict))) + + def save_plugin(self, _plugin_dict): + self.add(DB_PLUGINS_TABLE, ( + _plugin_dict['id'], + _plugin_dict['repoid'], + _plugin_dict['name'], + _plugin_dict['version']['installed'], + json.dumps(_plugin_dict))) + + def save_instance(self, _repo_id, _namespace, _instance, _descr): + self.add(DB_INSTANCE_TABLE, ( + _repo_id, + _namespace, + _instance, + _descr)) + + def get_repos(self, _id): + if not _id: + _id = '%' + rows = self.get_dict(DB_REPOS_TABLE, (_id,)) + plugin_list = [] + for row in rows: + plugin_list.append(json.loads(row['json'])) + if len(plugin_list) == 0: + plugin_list = None + return plugin_list + + def del_repo(self, _id): + """ + If a plugin is installed, it must be removed before the + repo can be deleted. Once all plugins are not installed, + then will remove the repo, plugin and all instances + """ + plugins_installed = self.get_plugins(True, _id) + self.logger.warning('################## TBD, aborting delete {}'.format(len(plugins_installed))) + + + #self.delete(DB_INSTANCE_TABLE, (_id, '%', '%',)) + #self.delete(DB_PLUGINS_TABLE, (_id, '%',)) + #self.delete(DB_REPOS_TABLE, (_id,)) + + def get_plugins(self, _installed, _repo_id=None, _plugin_id=None): + if not _repo_id: + _repo_id = '%' + if not _plugin_id: + _plugin_id = '%' + if _installed is None: + rows = self.get_dict(DB_PLUGINS_TABLE+'_all', (_repo_id, _plugin_id,)) + else: + rows = self.get_dict(DB_PLUGINS_TABLE, (_repo_id, _plugin_id, _installed,)) + plugin_list = [] + for row in rows: + plugin_list.append(json.loads(row['json'])) + if len(plugin_list) == 0: + plugin_list = None + return plugin_list + + def get_plugins_by_name(self, _installed, _repo_id=None, _plugin_name=None): + if not _repo_id: + _repo_id = '%' + if not _plugin_name: + _plugin_name = '%' + if _installed is None: + rows = self.get_dict(DB_PLUGINS_TABLE+'_all_name', (_repo_id, _plugin_name,)) + else: + rows = self.get_dict(DB_PLUGINS_TABLE+'_name', (_repo_id, _plugin_name, _installed,)) + plugin_list = [] + for row in rows: + plugin_list.append(json.loads(row['json'])) + if len(plugin_list) == 0: + plugin_list = None + return plugin_list + + def del_plugin(self, _repo_id, _plugin_id): + """ + Deletes the instance rows first due to constaints, then + deletes the plugin + """ + self.delete(DB_INSTANCE_TABLE, (_repo_id, _plugin_id, '%',)) + self.delete(DB_PLUGINS_TABLE, (_repo_id, _plugin_id,)) + + def del_instance(self, _repo, _namespace, _instance): + if not _repo: + _repo = '%' + return self.delete(DB_INSTANCE_TABLE, (_repo, _namespace, _instance,)) + + def get_instances(self, _repo=None, _namespace=None): + """ + createa a dict of namespaces that contain an array of instances + """ + if not _repo: + _repo = '%' + if not _namespace: + _namespace = '%' + rows_dict = {} + rows = self.get_dict(DB_INSTANCE_TABLE, (_repo, _namespace,)) + for row in rows: + if row['namespace'] not in rows_dict: + rows_dict[row['namespace']] = [] + instances = rows_dict[row['namespace']] + instances.append(row['instance']) + # rows_dict[row['namespace']] = row['instance'] + + return rows_dict + + def get_instances_full(self, _repo=None, _namespace=None): + if not _repo: + _repo = '%' + if not _namespace: + _namespace = '%' + rows_dict = {} + rows = self.get_dict(DB_INSTANCE_TABLE, (_repo, _namespace,)) + for row in rows: + rows_dict[row['namespace']] = row + return rows_dict + + @Backup(DB_CONFIG_NAME) + def backup(self, backup_folder): + self.export_sql(backup_folder) + + @Restore(DB_CONFIG_NAME) + def restore(self, backup_folder): + msg = self.import_sql(backup_folder) + if msg is None: + return 'Plugin Manifest Database Restored' + else: + return msg diff --git a/lib/db/db_scheduler.py b/lib/db/db_scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..72a8913043f05e35bc537dac8c0d242094163fc0 --- /dev/null +++ b/lib/db/db_scheduler.py @@ -0,0 +1,385 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import datetime +import sqlite3 +import uuid + +from lib.db.db import DB +from lib.common.decorators import Backup +from lib.common.decorators import Restore + +DB_TASK_TABLE = 'task' +DB_TRIGGER_TABLE = 'trigger' +DB_CONFIG_NAME = 'db_files-scheduler_db' + +sqlcmds = { + 'ct': [ + """ + CREATE TABLE IF NOT EXISTS task ( + taskid VARCHAR(255) NOT NULL, + area VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + namespace VARCHAR(255) NOT NULL, + instance VARCHAR(255), + funccall VARCHAR(255) NOT NULL, + lastran TIMESTAMP, + duration INTEGER, + priority INTEGER, + threadtype VARCHAR(255) + CHECK( threadtype IN ('inline', 'thread', 'process') ) NOT NULL, + active BOOLEAN DEFAULT 0, + description TEXT, + UNIQUE(area, title) + ) + """, + """ + CREATE TABLE IF NOT EXISTS trigger ( + uuid VARCHAR(255) NOT NULL, + area VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + timetype VARCHAR(255) + CHECK( timetype IN ('daily', 'weekly', 'interval', 'startup') ) NOT NULL, + timelimit INTEGER, + timeofday VARCHAR(255), + dayofweek VARCHAR(255) + CHECK( dayofweek IN ('Sunday', 'Monday', 'Tuesday', 'Wednesday', + 'Thursday', 'Friday', 'Saturday') ), + interval INTEGER, + randdur INTEGER, + UNIQUE(uuid) + FOREIGN KEY(area, title) REFERENCES task(area, title) + ) + """ + ], + 'dt': [ + """ + DROP TABLE IF EXISTS trigger + """, + """ + DROP TABLE IF EXISTS task + """ + ], + + 'task_add': + """ + INSERT INTO task ( + taskid, area, title, namespace, instance, funccall, + priority, threadtype, description + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? ) + """, + 'task_active_update': + """ + UPDATE task SET active=? + WHERE area LIKE ? AND title LIKE ? + """, + 'task_finish_update': + """ + UPDATE task SET active=0, + lastran=?, duration=? + WHERE area=? AND title=? + """, + 'task_get': + """ + SELECT * + FROM task + WHERE area LIKE ? AND title LIKE ? + ORDER BY task.area ASC, task.title ASC + """, + 'task_name_get': + """ + SELECT DISTINCT namespace FROM task + """, + 'task_instance_get': + """ + SELECT DISTINCT namespace, instance FROM task + """, + 'task_by_id_get': + """ + SELECT * + FROM task + WHERE taskid LIKE ? + ORDER BY task.area ASC, task.title ASC + """, + 'task_by_name_get': + """ + SELECT * + FROM task + WHERE namespace LIKE ? + ORDER BY task.area ASC, task.title ASC + """, + 'task_by_instance_get': + """ + SELECT * + FROM task + WHERE namespace LIKE ? AND instance LIKE ? + ORDER BY task.area ASC, task.title ASC + """, + 'task_by_active_get': + """ + SELECT * + FROM task + WHERE namespace LIKE ? AND active LIKE ? + ORDER BY task.area ASC, task.title ASC + """, + 'task_active_get': + """ + SELECT active + FROM task + WHERE taskid = ? + """, + 'task_num_active_get': + """ + SELECT count(*) + FROM task + WHERE active='1' + """, + + 'task_del': + """ + DELETE FROM task WHERE + area = ? AND title = ?; + """, + 'trigger_del': + """ + DELETE FROM trigger WHERE + area = ? AND title = ?; + """, + + 'trigger_add': + """ + INSERT OR REPLACE INTO trigger ( + uuid, area, title, timetype, timelimit, timeofday, dayofweek, interval, randdur ) + VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? ) + """, + 'trigger_by_uuid_get': + """ + SELECT * + FROM trigger + INNER JOIN task ON task.area = trigger.area + AND task.title = trigger.title + WHERE trigger.uuid = ? + """, + 'trigger_by_taskid_get': + """ + SELECT * + FROM trigger + INNER JOIN task ON task.area = trigger.area + AND task.title = trigger.title + WHERE task.taskid LIKE ? + ORDER BY task.area ASC, task.title ASC, trigger.timetype DESC + """, + 'trigger_by_type_get': + """ + SELECT * + FROM trigger + INNER JOIN task ON task.area = trigger.area + AND task.title = trigger.title + WHERE trigger.timetype LIKE ? + ORDER BY task.priority DESC + """, + 'trigger_by_uuid_del': + """ + DELETE FROM trigger WHERE uuid=? + """ +} + + +class DBScheduler(DB): + + def __init__(self, _config): + super().__init__(_config, _config['datamgmt'][DB_CONFIG_NAME], sqlcmds) + + def save_task(self, _area, _title, _namespace, _instance, _funccall, + _priority, _threadtype, _description): + """ + Returns true if the record was saved. If the record already exists, + it will return false. + """ + try: + id_ = str(uuid.uuid1()).upper() + self.add(DB_TASK_TABLE, ( + id_, + _area, + _title, + _namespace, + _instance, + _funccall, + _priority, + _threadtype, + _description + )) + return True + except sqlite3.IntegrityError: + return False + + def del_task(self, _area=None, _title=None): + """ + Deletes the task and associated triggers + """ + if not _area: + _area = '%' + if not _title: + _title = '%' + self.delete(DB_TRIGGER_TABLE, (_area, _title,)) + return self.delete(DB_TASK_TABLE, (_area, _title,)) + + def get_tasks(self, _area=None, _title=None): + if not _area: + _area = '%' + if not _title: + _title = '%' + return self.get_dict(DB_TASK_TABLE, ( + _area, + _title, + )) + + def get_tasks_by_name(self, _name=None, _instance=None): + if not _name: + _name = '%' + if not _instance: + return self.get_dict(DB_TASK_TABLE + '_by_name', ( + _name, + )) + else: + return self.get_dict(DB_TASK_TABLE + '_by_instance', ( + _name, _instance, + )) + + def get_tasks_by_active(self, _active='1', _name=None): + if not _name: + _name = '%' + return self.get_dict(DB_TASK_TABLE + '_by_active', ( + _name, _active, + )) + + def get_task(self, _id): + task = self.get_dict(DB_TASK_TABLE + '_by_id', ( + _id, + )) + if len(task) == 1: + return task[0] + else: + return None + + def get_task_names(self): + return self.get_dict(DB_TASK_TABLE + '_name') + + def get_task_instances(self): + return self.get_dict(DB_TASK_TABLE + '_instance') + + def start_task(self, _area, _title): + self.update(DB_TASK_TABLE + '_active', ( + 1, + _area, + _title, + )) + + def finish_task(self, _area, _title, _duration): + self.update(DB_TASK_TABLE + '_finish', ( + datetime.datetime.utcnow(), + _duration, + _area, + _title, + )) + + def reset_activity(self, _activity=False, _area=None, _title=None): + if not _area: + _area = '%' + if not _title: + _title = '%' + self.update(DB_TASK_TABLE + '_active', ( + _activity, + _area, + _title, + )) + + def get_active_status(self, _taskid): + res = self.get_dict(DB_TASK_TABLE + '_active', (_taskid,)) + if res: + return res[0]['active'] + else: + return None + + def get_num_active(self): + return self.get(DB_TASK_TABLE + '_num_active')[0][0] + + def save_trigger(self, _area, _title, _timetype, timeofday=None, + dayofweek=None, interval=-1, timelimit=-1, randdur=-1): + """ + timetype: daily, weekly, interval, startup + timelimit: maximum time it can run before terminating. -1 is not used + timeofday: used with daily and weekly. defines the time of day it runs + dayofweek: string for the day of the week. ex: Wednesday + interval: used with timetype: interval in minutes. task will run every x minutes + randdur: maximum in minutes. Interval only. Will add a randum amount + to the event start time up to the maximum minutes. -1 is not used. + """ + id_ = str(uuid.uuid1()).upper() + self.add(DB_TRIGGER_TABLE, ( + id_, + _area, + _title, + _timetype, + timelimit, + timeofday, + dayofweek, + interval, + randdur, + )) + return id_ + + def get_triggers_by_type(self, _timetype): + """ + Returns the list of triggers based on timetype and ordered + by priority + """ + if not _timetype: + _timetype = '%' + return self.get_dict(DB_TRIGGER_TABLE + '_by_type', (_timetype,)) + + def get_trigger(self, _uuid): + trigger = self.get_dict(DB_TRIGGER_TABLE + '_by_uuid', (_uuid,)) + if len(trigger) == 1: + return trigger[0] + else: + return None + + def get_triggers(self, _taskid=None): + """ + Returns all triggers ordered by area, name, timetype and + also provides the task information on each trigger + """ + if not _taskid: + _taskid = '%' + return self.get_dict(DB_TRIGGER_TABLE + '_by_taskid', (_taskid,)) + + def del_trigger(self, _uuid): + self.delete(DB_TRIGGER_TABLE + '_by_uuid', (_uuid,)) + + @Backup(DB_CONFIG_NAME) + def backup(self, backup_folder): + self.export_sql(backup_folder) + + @Restore(DB_CONFIG_NAME) + def restore(self, backup_folder): + msg = self.import_sql(backup_folder) + if msg is None: + msg = 'Scheduler Database Restored' + self.reset_activity() + return msg diff --git a/lib/db/db_temp.py b/lib/db/db_temp.py new file mode 100644 index 0000000000000000000000000000000000000000..7d401d8f91b231ccb22c0f4d21c0176d777ef2fc --- /dev/null +++ b/lib/db/db_temp.py @@ -0,0 +1,115 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import json +import datetime + +from lib.db.db import DB +from lib.common.decorators import Backup +from lib.common.decorators import Restore + +DB_TEMP_TABLE = 'temp' +DB_CONFIG_NAME = 'db_files-temp_db' + +sqlcmds = { + 'ct': [ + """ + CREATE TABLE IF NOT EXISTS temp ( + namespace VARCHAR(255) NOT NULL, + instance VARCHAR(255) NOT NULL, + value VARCHAR(255) NOT NULL, + last_update TIMESTAMP, + json TEXT NOT NULL, + UNIQUE(namespace, instance, value) + ) + """ + ], + 'dt': [ + """ + DROP TABLE IF EXISTS temp + """, + ], + + 'temp_add': + """ + INSERT OR REPLACE INTO temp ( + namespace, instance, value, last_update, json + ) VALUES ( ?, ?, ?, ?, ? ) + """, + 'temp_by_day_del': + """ + DELETE FROM temp WHERE namespace LIKE ? AND instance LIKE ? AND last_update < DATETIME('NOW',?) + """, + + 'temp_del': + """ + DELETE FROM temp WHERE namespace=? AND instance LIKE ? + """, + 'temp_get': + """ + SELECT * FROM temp WHERE + namespace=? AND instance=? AND value=? + """ +} + + +class DBTemp(DB): + + def __init__(self, _config): + super().__init__(_config, _config['datamgmt'][DB_CONFIG_NAME], sqlcmds) + + def save_json(self, _namespace, _instance, _value, _json): + """ + saves the json blob under a value item for the namespace/instance + """ + self.add(DB_TEMP_TABLE, ( + _namespace, + _instance, + _value, + datetime.datetime.utcnow(), + json.dumps(_json),)) + + def cleanup_temp(self, _namespace, _instance, _hours='-6 hours'): + """ + Removes all records for this namespace/instance that are over 6 hour old + """ + if not _namespace: + _namespace = '%' + if not _instance: + _instance = '%' + deleted = self.delete(DB_TEMP_TABLE + '_by_day', (_namespace, _instance, _hours,)) + self.sql_exec('VACUUM') + + def del_instance(self, _namespace, _instance): + """ + Removes all records for this namespace/instance + """ + if not _instance: + _instance = '%' + return self.delete(DB_TEMP_TABLE, (_namespace, _instance,)) + + def get_record(self, _namespace, _instance, _value): + return self.get_dict(DB_TEMP_TABLE, (_namespace, _instance, _value)) + + @Backup(DB_CONFIG_NAME) + def backup(self, backup_folder): + self.export_sql(backup_folder) + + @Restore(DB_CONFIG_NAME) + def restore(self, backup_folder): + return self.import_sql(backup_folder) diff --git a/lib/image_size/get_image_size.py b/lib/image_size/get_image_size.py new file mode 100644 index 0000000000000000000000000000000000000000..d0b50ff6f2a4dd84a920b08b46a5d0145d838310 --- /dev/null +++ b/lib/image_size/get_image_size.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import print_function +""" + +get_image_size.py +==================== + + :Name: get_image_size + :Purpose: extract image dimensions given a file path + + :Author: Paulo Scardine (based on code from Emmanuel VAÏSSE) + + :Created: 26/09/2013 + :Copyright: (c) Paulo Scardine 2013 + :Licence: MIT + + rocky4546: added webp format + +""" +import collections +import json +import os +import io +import re +import struct + +FILE_UNKNOWN = "Sorry, don't know how to get size for this file." + + +class UnknownImageFormat(Exception): + pass + + +types = collections.OrderedDict() +BMP = types['BMP'] = 'BMP' +GIF = types['GIF'] = 'GIF' +ICO = types['ICO'] = 'ICO' +JPEG = types['JPEG'] = 'JPEG' +PNG = types['PNG'] = 'PNG' +TIFF = types['TIFF'] = 'TIFF' +WEBP = types['WEBP'] = 'WEBP' + +image_fields = ['path', 'type', 'file_size', 'width', 'height'] + + +class Image(collections.namedtuple('Image', image_fields)): + + def to_str_row(self): + return ("%d\t%d\t%d\t%s\t%s" % ( + self.width, + self.height, + self.file_size, + self.type, + self.path.replace('\t', '\\t'), + )) + + def to_str_row_verbose(self): + return ("%d\t%d\t%d\t%s\t%s\t##%s" % ( + self.width, + self.height, + self.file_size, + self.type, + self.path.replace('\t', '\\t'), + self)) + + def to_str_json(self, indent=None): + return json.dumps(self._asdict(), indent=indent) + + +def get_image_size(file_path): + """ + Return (width, height) for a given img file content - no external + dependencies except the os and struct builtin modules + """ + img = get_image_metadata(file_path) + return (img.width, img.height) + + +def get_image_size_from_bytesio(input, size): + """ + Return (width, height) for a given img file content - no external + dependencies except the os and struct builtin modules + + Args: + input (io.IOBase): io object support read & seek + size (int): size of buffer in byte + """ + img = get_image_metadata_from_bytesio(input, size) + return (img.width, img.height) + + +def get_image_metadata(file_path): + """ + Return an `Image` object for a given img file content - no external + dependencies except the os and struct builtin modules + + Args: + file_path (str): path to an image file + + Returns: + Image: (path, type, file_size, width, height) + """ + size = os.path.getsize(file_path) + + # be explicit with open arguments - we need binary mode + with io.open(file_path, "rb") as input: + return get_image_metadata_from_bytesio(input, size, file_path) + + +def get_image_metadata_from_bytesio(input, size, file_path=None): + """ + Return an `Image` object for a given img file content - no external + dependencies except the os and struct builtin modules + + Args: + input (io.IOBase): io object support read & seek + size (int): size of buffer in byte + file_path (str): path to an image file + + Returns: + Image: (path, type, file_size, width, height) + """ + height = -1 + width = -1 + data = input.read(40) + msg = " raised while trying to decode as JPEG." + + if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'): + # GIFs + imgtype = GIF + w, h = struct.unpack("= 24) and data.startswith(b'\211PNG\r\n\032\n') + and (data[12:16] == b'IHDR')): + # PNGs + imgtype = PNG + w, h = struct.unpack(">LL", data[16:24]) + width = int(w) + height = int(h) + elif (size >= 16) and data.startswith(b'\211PNG\r\n\032\n'): + # older PNGs + imgtype = PNG + w, h = struct.unpack(">LL", data[8:16]) + width = int(w) + height = int(h) + elif (size >= 2) and data.startswith(b'\377\330'): + # JPEG + imgtype = JPEG + input.seek(0) + input.read(2) + b = input.read(1) + try: + while (b and ord(b) != 0xDA): + while (ord(b) != 0xFF): + b = input.read(1) + while (ord(b) == 0xFF): + b = input.read(1) + if (ord(b) >= 0xC0 and ord(b) <= 0xC3): + input.read(3) + h, w = struct.unpack(">HH", input.read(4)) + break + else: + input.read( + int(struct.unpack(">H", input.read(2))[0]) - 2) + b = input.read(1) + width = int(w) + height = int(h) + except struct.error: + raise UnknownImageFormat("StructError" + msg) + except ValueError: + raise UnknownImageFormat("ValueError" + msg) + except Exception as e: + raise UnknownImageFormat(e.__class__.__name__ + msg) + elif (size >= 26) and data.startswith(b'BM'): + # BMP + imgtype = 'BMP' + headersize = struct.unpack("= 40: + w, h = struct.unpack("= 30) and \ + data.startswith(b'RIFF') and \ + data[8:15] == b'WEBPVP8': + imgtype = WEBP + format = data[15:16] + if format == b' ': + s = data[26:30] + w, h = struct.unpack("= 8) and data[:4] in (b"II\052\000", b"MM\000\052"): + # Standard TIFF, big- or little-endian + # BigTIFF and other different but TIFF-like formats are not + # supported currently + imgtype = TIFF + byteOrder = data[:2] + boChar = ">" if byteOrder == "MM" else "<" + # maps TIFF type id to size (in bytes) + # and python format char for struct + tiffTypes = { + 1: (1, boChar + "B"), # BYTE + 2: (1, boChar + "c"), # ASCII + 3: (2, boChar + "H"), # SHORT + 4: (4, boChar + "L"), # LONG + 5: (8, boChar + "LL"), # RATIONAL + 6: (1, boChar + "b"), # SBYTE + 7: (1, boChar + "c"), # UNDEFINED + 8: (2, boChar + "h"), # SSHORT + 9: (4, boChar + "l"), # SLONG + 10: (8, boChar + "ll"), # SRATIONAL + 11: (4, boChar + "f"), # FLOAT + 12: (8, boChar + "d") # DOUBLE + } + ifdOffset = struct.unpack(boChar + "L", data[4:8])[0] + try: + countSize = 2 + input.seek(ifdOffset) + ec = input.read(countSize) + ifdEntryCount = struct.unpack(boChar + "H", ec)[0] + # 2 bytes: TagId + 2 bytes: type + 4 bytes: count of values + 4 + # bytes: value offset + ifdEntrySize = 12 + for i in range(ifdEntryCount): + entryOffset = ifdOffset + countSize + i * ifdEntrySize + input.seek(entryOffset) + tag = input.read(2) + tag = struct.unpack(boChar + "H", tag)[0] + if(tag == 256 or tag == 257): + # if type indicates that value fits into 4 bytes, value + # offset is not an offset but value itself + type = input.read(2) + type = struct.unpack(boChar + "H", type)[0] + if type not in tiffTypes: + raise UnknownImageFormat( + "Unkown TIFF field type:" + + str(type)) + typeSize = tiffTypes[type][0] + typeChar = tiffTypes[type][1] + input.seek(entryOffset + 8) + value = input.read(typeSize) + value = int(struct.unpack(typeChar, value)[0]) + if tag == 256: + width = value + else: + height = value + if width > -1 and height > -1: + break + except Exception as e: + raise UnknownImageFormat(str(e)) + elif size >= 2: + # see http://en.wikipedia.org/wiki/ICO_(file_format) + imgtype = 'ICO' + input.seek(0) + reserved = input.read(2) + if 0 != struct.unpack(" 1: + import warnings + warnings.warn("ICO File contains more than one image") + # http://msdn.microsoft.com/en-us/library/ms997538.aspx + w = input.read(1) + h = input.read(1) + width = ord(w) + height = ord(h) + else: + raise UnknownImageFormat(FILE_UNKNOWN) + + return Image(path=file_path, + type=imgtype, + file_size=size, + width=width, + height=height) + + +import unittest + + +class Test_get_image_size(unittest.TestCase): + data = [{ + 'path': 'lookmanodeps.png', + 'width': 251, + 'height': 208, + 'file_size': 22228, + 'type': 'PNG'}] + + def setUp(self): + pass + + def test_get_image_size_from_bytesio(self): + img = self.data[0] + p = img['path'] + with io.open(p, 'rb') as fp: + b = fp.read() + fp = io.BytesIO(b) + sz = len(b) + output = get_image_size_from_bytesio(fp, sz) + self.assertTrue(output) + self.assertEqual(output, + (img['width'], + img['height'])) + + def test_get_image_metadata_from_bytesio(self): + img = self.data[0] + p = img['path'] + with io.open(p, 'rb') as fp: + b = fp.read() + fp = io.BytesIO(b) + sz = len(b) + output = get_image_metadata_from_bytesio(fp, sz) + self.assertTrue(output) + for field in image_fields: + self.assertEqual(getattr(output, field), None if field == 'path' else img[field]) + + def test_get_image_metadata(self): + img = self.data[0] + output = get_image_metadata(img['path']) + self.assertTrue(output) + for field in image_fields: + self.assertEqual(getattr(output, field), img[field]) + + def test_get_image_metadata__ENOENT_OSError(self): + with self.assertRaises(OSError): + get_image_metadata('THIS_DOES_NOT_EXIST') + + def test_get_image_metadata__not_an_image_UnknownImageFormat(self): + with self.assertRaises(UnknownImageFormat): + get_image_metadata('README.rst') + + def test_get_image_size(self): + img = self.data[0] + output = get_image_size(img['path']) + self.assertTrue(output) + self.assertEqual(output, + (img['width'], + img['height'])) + + def tearDown(self): + pass + + +def main(argv=None): + """ + Print image metadata fields for the given file path. + + Keyword Arguments: + argv (list): commandline arguments (e.g. sys.argv[1:]) + Returns: + int: zero for OK + """ + import logging + import optparse + import sys + + prs = optparse.OptionParser( + usage="%prog [-v|--verbose] [--json|--json-indent] []", + description="Print metadata for the given image paths " + "(without image library bindings).") + + prs.add_option('--json', + dest='json', + action='store_true') + prs.add_option('--json-indent', + dest='json_indent', + action='store_true') + + prs.add_option('-v', '--verbose', + dest='verbose', + action='store_true',) + prs.add_option('-q', '--quiet', + dest='quiet', + action='store_true',) + prs.add_option('-t', '--test', + dest='run_tests', + action='store_true',) + + argv = list(argv) if argv is not None else sys.argv[1:] + (opts, args) = prs.parse_args(args=argv) + loglevel = logging.INFO + if opts.verbose: + loglevel = logging.DEBUG + elif opts.quiet: + loglevel = logging.ERROR + logging.basicConfig(level=loglevel) + log = logging.getLogger() + log.debug('argv: %r', argv) + log.debug('opts: %r', opts) + log.debug('args: %r', args) + + if opts.run_tests: + import sys + sys.argv = [sys.argv[0]] + args + import unittest + return unittest.main() + + output_func = Image.to_str_row + if opts.json_indent: + import functools + output_func = functools.partial(Image.to_str_json, indent=2) + elif opts.json: + output_func = Image.to_str_json + elif opts.verbose: + output_func = Image.to_str_row_verbose + + EX_OK = 0 + EX_NOT_OK = 2 + + if len(args) < 1: + prs.print_help() + print('\n') + prs.error("You must specify one or more paths to image files") + + errors = [] + for path_arg in args: + try: + img = get_image_metadata(path_arg) + print(output_func(img)) + except KeyboardInterrupt: + raise + except OSError as e: + log.error((path_arg, e)) + errors.append((path_arg, e)) + except Exception as e: + log.exception(e) + errors.append((path_arg, e)) + pass + if len(errors): + import pprint + print("ERRORS", file=sys.stderr) + print("======", file=sys.stderr) + print(pprint.pformat(errors, indent=2), file=sys.stderr) + return EX_NOT_OK + return EX_OK + + +if __name__ == "__main__": + import sys + sys.exit(main(argv=sys.argv[1:])) diff --git a/lib/m3u8/LICENSE b/lib/m3u8/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..b8fc57ddcc9300dfd3be58e2d503f47c88075fd2 --- /dev/null +++ b/lib/m3u8/LICENSE @@ -0,0 +1,11 @@ +m3u8 is licensed under the MIT License: + +The MIT License + +Copyright (c) 2012 globo.com webmedia@corp.globo.com + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/m3u8/MANIFEST.in b/lib/m3u8/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..39fb846ee1a5cfa004c02bcaad750ef0c87fbe03 --- /dev/null +++ b/lib/m3u8/MANIFEST.in @@ -0,0 +1,3 @@ +include requirements.txt +include LICENSE +include README.rst \ No newline at end of file diff --git a/lib/m3u8/README.rst b/lib/m3u8/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..4574187f55eb1f1c2871287e8e3c250c7bc6580f --- /dev/null +++ b/lib/m3u8/README.rst @@ -0,0 +1,263 @@ +.. image:: https://travis-ci.org/globocom/m3u8.svg + :target: https://travis-ci.org/globocom/m3u8 + +.. image:: https://coveralls.io/repos/globocom/m3u8/badge.png?branch=master + :target: https://coveralls.io/r/globocom/m3u8?branch=master + +.. image:: https://badge.fury.io/py/m3u8.svg + :target: https://badge.fury.io/py/m3u8 + +m3u8 +==== + +Python `m3u8`_ parser. + +Documentation +============= + +The basic usage is to create a playlist object from uri, file path or +directly from a string: + +.. code-block:: python + + import m3u8 + + m3u8_obj = m3u8.load('http://videoserver.com/playlist.m3u8') # this could also be an absolute filename + print m3u8_obj.segments + print m3u8_obj.target_duration + + # if you already have the content as string, use + + m3u8_obj = m3u8.loads('#EXTM3U8 ... etc ... ') + +Supported tags +============== + +* `#EXT-X-TARGETDURATION`_ +* `#EXT-X-MEDIA-SEQUENCE`_ +* `#EXT-X-DISCONTINUITY-SEQUENCE`_ +* `#EXT-X-PROGRAM-DATE-TIME`_ +* `#EXT-X-MEDIA`_ +* `#EXT-X-PLAYLIST-TYPE`_ +* `#EXT-X-KEY`_ +* `#EXT-X-STREAM-INF`_ +* `#EXT-X-VERSION`_ +* #EXT-X-ALLOW-CACHE +* `#EXT-X-ENDLIST`_ +* `#EXTINF`_ +* `#EXT-X-I-FRAMES-ONLY`_ +* `#EXT-X-BYTERANGE`_ +* `#EXT-X-I-FRAME-STREAM-INF`_ +* `#EXT-X-DISCONTINUITY`_ +* #EXT-X-CUE-OUT +* #EXT-X-CUE-OUT-CONT +* #EXT-X-CUE-IN +* #EXT-X-CUE-SPAN +* #EXT-OATCLS-SCTE35 +* `#EXT-X-INDEPENDENT-SEGMENTS`_ +* `#EXT-X-MAP`_ +* `#EXT-X-START`_ +* #EXT-X-SERVER-CONTROL +* #EXT-X-PART-INF +* #EXT-X-PART +* #EXT-X-RENDITION-REPORT +* #EXT-X-SKIP +* `#EXT-X-SESSION-DATA`_ + +Encryption keys +--------------- + +The segments may be or not encrypted. The ``keys`` attribute list will +be an list with all the different keys as described with `#EXT-X-KEY`_: + +Each key has the next properties: + +- ``method``: ex.: "AES-128" +- ``uri``: the key uri, ex.: "http://videoserver.com/key.bin" +- ``iv``: the initialization vector, if available. Otherwise ``None``. + +If no ``#EXT-X-KEY`` is found, the ``keys`` list will have a unique element ``None``. Multiple keys are supported. + +If unencrypted and encrypted segments are mixed in the M3U8 file, then the list will contain a ``None`` element, with one +or more keys afterwards. + +To traverse the list of keys available: + +.. code-block:: python + + import m3u8 + + m3u8_obj = m3u8.loads('#EXTM3U8 ... etc ...') + len(m3u8_obj.keys) => returns the number of keys available in the list (normally 1) + for key in m3u8_obj.keys: + if key: # First one could be None + key.uri + key.method + key.iv + + +Getting segments encrypted with one key +--------------------------------------- + +There are cases where listing segments for a given key is important. It's possible to +retrieve the list of segments encrypted with one key via ``by_key`` method in the +``segments`` list. + +Example of getting the segments with no encryption: + +.. code-block:: python + + import m3u8 + + m3u8_obj = m3u8.loads('#EXTM3U8 ... etc ...') + segmk1 = m3u8_obj.segments.by_key(None) + + # Get the list of segments encrypted using last key + segm = m3u8_obj.segments.by_key( m3u8_obj.keys[-1] ) + + +With this method, is now possible also to change the key from some of the segments programatically: + + +.. code-block:: python + + import m3u8 + + m3u8_obj = m3u8.loads('#EXTM3U8 ... etc ...') + + # Create a new Key and replace it + new_key = m3u8.Key("AES-128", "/encrypted/newkey.bin", None, iv="0xf123ad23f22e441098aa87ee") + for segment in m3u8_obj.segments.by_key( m3u8_obj.keys[-1] ): + segm.key = new_key + # Remember to sync the key from the list as well + m3u8_obj.keys[-1] = new_key + + + +Variant playlists (variable bitrates) +------------------------------------- + +A playlist can have a list to other playlist files, this is used to +represent multiple bitrates videos, and it's called `variant streams`_. +See an `example here`_. + +.. code-block:: python + + variant_m3u8 = m3u8.loads('#EXTM3U8 ... contains a variant stream ...') + variant_m3u8.is_variant # in this case will be True + + for playlist in variant_m3u8.playlists: + playlist.uri + playlist.stream_info.bandwidth + +the playlist object used in the for loop above has a few attributes: + +- ``uri``: the url to the stream +- ``stream_info``: a ``StreamInfo`` object (actually a namedtuple) with + all the attributes available to `#EXT-X-STREAM-INF`_ +- ``media``: a list of related ``Media`` objects with all the attributes + available to `#EXT-X-MEDIA`_ +- ``playlist_type``: the type of the playlist, which can be one of `VOD`_ + (video on demand) or `EVENT`_ + +**NOTE: the following attributes are not implemented yet**, follow +`issue 4`_ for updates + +- ``alternative_audios``: its an empty list, unless it's a playlist + with `Alternative audio`_, in this case it's a list with ``Media`` + objects with all the attributes available to `#EXT-X-MEDIA`_ +- ``alternative_videos``: same as ``alternative_audios`` + +A variant playlist can also have links to `I-frame playlists`_, which are used +to specify where the I-frames are in a video. See `Apple's documentation`_ on +this for more information. These I-frame playlists can be accessed in a similar +way to regular playlists. + +.. code-block:: python + + variant_m3u8 = m3u8.loads('#EXTM3U ... contains a variant stream ...') + + for iframe_playlist in variant_m3u8.iframe_playlists: + iframe_playlist.uri + iframe_playlist.iframe_stream_info.bandwidth + +The iframe_playlist object used in the for loop above has a few attributes: + +- ``uri``: the url to the I-frame playlist +- ``base_uri``: the base uri of the variant playlist (if given) +- ``iframe_stream_info``: a ``StreamInfo`` object (same as a regular playlist) + +Custom tags +----------- + +Quoting the documentation:: + + Lines that start with the character '#' are either comments or tags. + Tags begin with #EXT. They are case-sensitive. All other lines that + begin with '#' are comments and SHOULD be ignored. + +This library ignores all the non standard tags by default. If you want them to be collected while loading the file content, +you need to pass a function to the `load/loads` functions, following the example below: + +.. code-block:: python + + import m3u8 + + def get_movie(line, data, lineno): + if line.startswith('#MOVIE-NAME:'): + custom_tag = line.split(':') + data['movie'] = custom_tag[1].strip() + + m3u8_obj = m3u8.load('http://videoserver.com/playlist.m3u8', custom_tags_parser=get_movie) + print(m3u8_obj.data['movie']) # million dollar baby + + +Running Tests +============= + +.. code-block:: bash + + $ ./runtests + +Contributing +============ + +All contribution is welcome, but we will merge a pull request if, and only if, it + +- has tests +- follows the code conventions + +If you plan to implement a new feature or something that will take more +than a few minutes, please open an issue to make sure we don't work on +the same thing. + +.. _m3u8: https://tools.ietf.org/html/rfc8216 +.. _#EXT-X-VERSION: https://tools.ietf.org/html/rfc8216#section-4.3.1.2 +.. _#EXTINF: https://tools.ietf.org/html/rfc8216#section-4.3.2.1 +.. _#EXT-X-BYTERANGE: https://tools.ietf.org/html/rfc8216#section-4.3.2.2 +.. _#EXT-X-DISCONTINUITY: https://tools.ietf.org/html/rfc8216#section-4.3.2.3 +.. _#EXT-X-KEY: https://tools.ietf.org/html/rfc8216#section-4.3.2.4 +.. _#EXT-X-MAP: https://tools.ietf.org/html/rfc8216#section-4.3.2.5 +.. _#EXT-X-PROGRAM-DATE-TIME: https://tools.ietf.org/html/rfc8216#section-4.3.2.6 +.. _#EXT-X-DATERANGE: https://tools.ietf.org/html/rfc8216#section-4.3.2.7 +.. _#EXT-X-TARGETDURATION: https://tools.ietf.org/html/rfc8216#section-4.3.3.1 +.. _#EXT-X-MEDIA-SEQUENCE: https://tools.ietf.org/html/rfc8216#section-4.3.3.2 +.. _#EXT-X-DISCONTINUITY-SEQUENCE: https://tools.ietf.org/html/rfc8216#section-4.3.3.3 +.. _#EXT-X-ENDLIST: https://tools.ietf.org/html/rfc8216#section-4.3.3.4 +.. _#EXT-X-PLAYLIST-TYPE: https://tools.ietf.org/html/rfc8216#section-4.3.3.5 +.. _#EXT-X-I-FRAMES-ONLY: https://tools.ietf.org/html/rfc8216#section-4.3.3.6 +.. _#EXT-X-MEDIA: https://tools.ietf.org/html/rfc8216#section-4.3.4.1 +.. _#EXT-X-STREAM-INF: https://tools.ietf.org/html/rfc8216#section-4.3.4.2 +.. _#EXT-X-I-FRAME-STREAM-INF: https://tools.ietf.org/html/rfc8216#section-4.3.4.3 +.. _#EXT-X-SESSION-DATA: https://tools.ietf.org/html/rfc8216#section-4.3.4.4 +.. _#EXT-X-INDEPENDENT-SEGMENTS: https://tools.ietf.org/html/rfc8216#section-4.3.5.1 +.. _#EXT-X-START: https://tools.ietf.org/html/rfc8216#section-4.3.5.2 +.. _issue 1: https://github.com/globocom/m3u8/issues/1 +.. _variant streams: https://tools.ietf.org/html/rfc8216#section-6.2.4 +.. _example here: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-8.5 +.. _issue 4: https://github.com/globocom/m3u8/issues/4 +.. _I-frame playlists: https://tools.ietf.org/html/rfc8216#section-4.3.4.3 +.. _Apple's documentation: https://developer.apple.com/library/ios/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-I_FRAME_PLAYLIST +.. _Alternative audio: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-8.7 +.. _VOD: https://developer.apple.com/library/mac/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-TNTAG2 +.. _EVENT: https://developer.apple.com/library/mac/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-EVENT_PLAYLIST diff --git a/lib/m3u8/__init__.py b/lib/m3u8/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2d07feadca77e4795106d5fabc523b5872ad7ecf --- /dev/null +++ b/lib/m3u8/__init__.py @@ -0,0 +1,79 @@ +# coding: utf-8 +# Copyright 2014 Globo.com Player authors. All rights reserved. +# Use of this source code is governed by a MIT License +# license that can be found in the LICENSE file. + +import logging +import os +import sys + +from .httpclient import DefaultHTTPClient, _parsed_url +from .model import (M3U8, Segment, SegmentList, PartialSegment, + PartialSegmentList, Key, Playlist, IFramePlaylist, + Media, MediaList, PlaylistList, Start, + RenditionReport, RenditionReportList, ServerControl, + Skip, PartInformation, PreloadHint, DateRange, + DateRangeList) +from .parser import parse, is_url, ParseError + + +__all__ = ('M3U8', 'Segment', 'SegmentList', 'PartialSegment', + 'PartialSegmentList', 'Key', 'Playlist', 'IFramePlaylist', + 'Media', 'MediaList', 'PlaylistList', 'Start', 'RenditionReport', + 'RenditionReportList', 'ServerControl', 'Skip', 'PartInformation', + 'PreloadHint' 'DateRange', 'DateRangeList', 'loads', 'load', + 'parse', 'ParseError') + +LOGGER = None + +def loads(content, uri=None, custom_tags_parser=None): + ''' + Given a string with a m3u8 content, returns a M3U8 object. + Optionally parses a uri to set a correct base_uri on the M3U8 object. + Raises ValueError if invalid content + ''' + global LOGGER + if LOGGER is None: + LOGGER = logging.getLogger(__name__) + if not content.startswith('#EXTM3U'): + LOGGER.warning('INVALID m3u format: #EXTM3U missing {}'.format(uri)) + return None + if uri is None: + return M3U8(content, custom_tags_parser=custom_tags_parser) + else: + base_uri = _parsed_url(uri) + return M3U8(content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) + + +def load(uri, timeout=9, headers={}, custom_tags_parser=None, http_client=DefaultHTTPClient(), verify_ssl=True, http_session=None): + ''' + Retrieves the content from a given URI and returns a M3U8 object. + Raises ValueError if invalid content or IOError if request fails. + ''' + global LOGGER + if LOGGER is None: + LOGGER = logging.getLogger(__name__) + if is_url(uri): + content, base_uri = http_client.download(uri, timeout, headers, verify_ssl, http_session) + if content is None: + LOGGER.warning('Unable to obtain m3u file {}'.format(uri)) + return None + if not content.startswith('#EXTM3U'): + LOGGER.warning('INVALID m3u format: #EXTM3U missing {}'.format(uri)) + return None + return M3U8(content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) + else: + return _load_from_file(uri, custom_tags_parser) + + +def _load_from_file(uri, custom_tags_parser=None): + global LOGGER + if LOGGER is None: + LOGGER = logging.getLogger(__name__) + with open(uri, encoding='utf8') as fileobj: + raw_content = fileobj.read().strip() + base_uri = os.path.dirname(uri) + if not raw_content.startswith('#EXTM3U'): + LOGGER.warning('INVALID m3u format: #EXTM3U missing {}'.format(uri)) + return None + return M3U8(raw_content, base_uri=base_uri, custom_tags_parser=custom_tags_parser) diff --git a/lib/m3u8/httpclient.py b/lib/m3u8/httpclient.py new file mode 100644 index 0000000000000000000000000000000000000000..6e75135d0dd62b7e5561ae841ab4fd619795eb11 --- /dev/null +++ b/lib/m3u8/httpclient.py @@ -0,0 +1,60 @@ +import logging +import posixpath +import ssl +import sys +import urllib +from urllib.error import HTTPError +from urllib.parse import urlparse, urljoin +import urllib.request + + +def _parsed_url(url): + parsed_url = urlparse(url) + prefix = parsed_url.scheme + '://' + parsed_url.netloc + base_path = posixpath.normpath(parsed_url.path + '/..') + return urljoin(prefix, base_path) + + +class DefaultHTTPClient: + + def __init__(self, proxies=None): + self.proxies = proxies + self.base_uri = None + self.logger = None + + def download(self, uri, timeout=9, headers={}, verify_ssl=True, http_session=None): + content = self.get_uri(uri, timeout, headers, verify_ssl, http_session) + return content, self.base_uri + + def get_uri(self, _uri, _timeout, _headers, _verify_ssl, _http_session): + if self.logger is None: + self.logger = logging.getLogger(__name__) + + if _http_session: + resp = _http_session.get(_uri, headers=_headers, timeout=_timeout) + x = resp.text + self.base_uri = _parsed_url(str(resp.url)) + resp.raise_for_status() + return x + + else: + proxy_handler = urllib.request.ProxyHandler(self.proxies) + https_handler = HTTPSHandler(verify_ssl=_verify_ssl) + opener = urllib.request.build_opener(proxy_handler, https_handler) + opener.addheaders = _headers.items() + resource = opener.open(_uri, timeout=_timeout) + self.base_uri = _parsed_url(resource.geturl()) + content = resource.read().decode( + resource.headers.get_content_charset(failobj="utf-8") + ) + return content + + +class HTTPSHandler: + + def __new__(self, verify_ssl=True): + context = ssl.create_default_context() + if not verify_ssl: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return urllib.request.HTTPSHandler(context=context) diff --git a/lib/m3u8/iso8601/LICENSE b/lib/m3u8/iso8601/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..4eb54eaff9faf7cab7f857965b99678837f4d5fa --- /dev/null +++ b/lib/m3u8/iso8601/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2007 - 2015 Michael Twomey + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/m3u8/iso8601/MANIFEST.in b/lib/m3u8/iso8601/MANIFEST.in new file mode 100644 index 0000000000000000000000000000000000000000..ea47448e5be19a9a501ae7493cbdd3f3ef2a5fb3 --- /dev/null +++ b/lib/m3u8/iso8601/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include iso8601 *.py +include README.rst LICENSE tox.ini setup.py *requirements.txt \ No newline at end of file diff --git a/lib/m3u8/iso8601/README.rst b/lib/m3u8/iso8601/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..a9f1761e09bf901486e7f6ae1447a64dbbdc7050 --- /dev/null +++ b/lib/m3u8/iso8601/README.rst @@ -0,0 +1,190 @@ +Simple module to parse ISO 8601 dates + +This module parses the most common forms of ISO 8601 date strings (e.g. +2007-01-14T20:34:22+00:00) into datetime objects. + +>>> import iso8601 +>>> iso8601.parse_date("2007-01-25T12:00:00Z") +datetime.datetime(2007, 1, 25, 12, 0, tzinfo=) +>>> + +See the LICENSE file for the license this package is released under. + +If you want more full featured parsing look at: + +- http://labix.org/python-dateutil - python-dateutil + +Parsed Formats +============== + +You can parse full date + times, or just the date. In both cases a datetime instance is returned but with missing times defaulting to 0, and missing days / months defaulting to 1. + +Dates +----- + +- YYYY-MM-DD +- YYYYMMDD +- YYYY-MM (defaults to 1 for the day) +- YYYY (defaults to 1 for month and day) + +Times +----- + +- hh:mm:ss.nn +- hhmmss.nn +- hh:mm (defaults to 0 for seconds) +- hhmm (defaults to 0 for seconds) +- hh (defaults to 0 for minutes and seconds) + +Time Zones +---------- + +- Nothing, will use the default timezone given (which in turn defaults to UTC). +- Z (UTC) +- +/-hh:mm +- +/-hhmm +- +/-hh + +Where it Differs From ISO 8601 +============================== + +Known differences from the ISO 8601 spec: + +- You can use a " " (space) instead of T for separating date from time. +- Days and months without a leading 0 (2 vs 02) will be parsed. +- If time zone information is omitted the default time zone given is used (which in turn defaults to UTC). Use a default of None to yield naive datetime instances. + +Homepage +======== + +- Documentation: http://pyiso8601.readthedocs.org/ +- Source: https://bitbucket.org/micktwomey/pyiso8601/ + +This was originally hosted at https://code.google.com/p/pyiso8601/ + +References +========== + +- http://en.wikipedia.org/wiki/ISO_8601 + +- http://www.cl.cam.ac.uk/~mgk25/iso-time.html - simple overview + +- http://hydracen.com/dx/iso8601.htm - more detailed enumeration of valid formats. + +Testing +======= + +1. pip install -r dev-requirements.txt +2. tox + +Note that you need all the pythons installed to perform a tox run (see below). Homebrew helps a lot on the mac, however you wind up having to add cellars to your PATH or symlinking the pythonX.Y executables. + +Alternatively, to test only with your current python: + +1. pip install -r dev-requirements.txt +2. py.test --verbose iso8601 + +Supported Python Versions +========================= + +Tested against: + +- Python 2.6 +- Python 2.7 +- Python 3.2 +- Python 3.3 +- Python 3.4 +- Python 3.5 +- Python 3.6 +- PyPy +- PyPy 3 + +Python 3.0 and 3.1 are untested but should work (tests didn't run under them when last tried). + +Jython is untested but should work (tests failed to run). + +Python 2.5 is not supported (too old for the tests for the most part). It could work with some small changes but I'm not supporting it. + +Changes +======= + +0.1.12 +------ + +* Fix class reference for iso8601.Utc in module docstring (thanks to felixschwarz in https://bitbucket.org/micktwomey/pyiso8601/pull-requests/7/fix-class-reference-for-iso8601utc-in/diff) + +0.1.11 +------ + +* Remove logging (thanks to Quentin Pradet in https://bitbucket.org/micktwomey/pyiso8601/pull-requests/6/remove-debug-logging/diff) +* Add support for , as separator for fractional part (thanks to ecksun in https://bitbucket.org/micktwomey/pyiso8601/pull-requests/5/add-support-for-as-separator-for/diff) +* Add Python 3.4 and 3.5 to tox test config. +* Add PyPy 3 to tox test config. +* Link to documentation at http://pyiso8601.readthedocs.org/ + + +0.1.10 +------ + +* Fixes https://bitbucket.org/micktwomey/pyiso8601/issue/14/regression-yyyy-mm-no-longer-parses (thanks to Kevin Gill for reporting) +* Adds YYYY as a valid date (uses 1 for both month and day) +* Woo, semantic versioning, .10 at last. + +0.1.9 +----- + +* Lots of fixes tightening up parsing from jdanjou. In particular more invalid cases are treated as errors. Also includes fixes for tests (which is how these invalid cases got in in the first place). +* Release addresses https://bitbucket.org/micktwomey/pyiso8601/issue/13/new-release-based-on-critical-bug-fix + +0.1.8 +----- + +* Remove +/- chars from README.rst and ensure tox tests run using LC_ALL=C. The setup.py egg_info command was failing in python 3.* on some setups (basically any where the system encoding wasn't UTF-8). (https://bitbucket.org/micktwomey/pyiso8601/issue/10/setuppy-broken-for-python-33) (thanks to klmitch) + +0.1.7 +----- + +* Fix parsing of microseconds (https://bitbucket.org/micktwomey/pyiso8601/issue/9/regression-parsing-microseconds) (Thanks to dims and bnemec) + +0.1.6 +----- + +* Correct negative timezone offsets (https://bitbucket.org/micktwomey/pyiso8601/issue/8/015-parses-negative-timezones-incorrectly) (thanks to Jonathan Lange) + +0.1.5 +----- + +* Wow, it's alive! First update since 2007 +* Moved over to https://bitbucket.org/micktwomey/pyiso8601 +* Add support for python 3. https://code.google.com/p/pyiso8601/issues/detail?id=23 (thanks to zefciu) +* Switched to py.test and tox for testing +* Make seconds optional in date format ("1997-07-16T19:20+01:00" now valid). https://bitbucket.org/micktwomey/pyiso8601/pull-request/1/make-the-inclusion-of-seconds-optional-in/diff (thanks to Chris Down) +* Correctly raise ParseError for more invalid inputs (https://bitbucket.org/micktwomey/pyiso8601/issue/1/raise-parseerror-for-invalid-input) (thanks to manish.tomar) +* Support more variations of ISO 8601 dates, times and time zone specs. +* Fix microsecond rounding issues (https://bitbucket.org/micktwomey/pyiso8601/issue/2/roundoff-issues-when-parsing-decimal) (thanks to nielsenb@jetfuse.net) +* Fix pickling and deepcopy of returned datetime objects (https://bitbucket.org/micktwomey/pyiso8601/issue/3/dates-returned-by-parse_date-do-not) (thanks to fogathmann and john@openlearning.com) +* Fix timezone offsets without a separator (https://bitbucket.org/micktwomey/pyiso8601/issue/4/support-offsets-without-a-separator) (thanks to joe.walton.gglcd) +* "Z" produces default timezone if one is specified (https://bitbucket.org/micktwomey/pyiso8601/issue/5/z-produces-default-timezone-if-one-is) (thanks to vfaronov). This one may cause problems if you've been relying on default_timezone to use that timezone instead of UTC. Strictly speaking that was wrong but this is potentially backwards incompatible. +* Handle compact date format (https://bitbucket.org/micktwomey/pyiso8601/issue/6/handle-compact-date-format) (thanks to rvandolson@esri.com) + +0.1.4 +----- + +* The default_timezone argument wasn't being passed through correctly, UTC was being used in every case. Fixes issue 10. + +0.1.3 +----- + +* Fixed the microsecond handling, the generated microsecond values were way too small. Fixes issue 9. + +0.1.2 +----- + +* Adding ParseError to __all__ in iso8601 module, allows people to import it. Addresses issue 7. +* Be a little more flexible when dealing with dates without leading zeroes. This violates the spec a little, but handles more dates as seen in the field. Addresses issue 6. +* Allow date/time separators other than T. + +0.1.1 +----- + +* When parsing dates without a timezone the specified default is used. If no default is specified then UTC is used. Addresses issue 4. diff --git a/lib/m3u8/iso8601/__init__.py b/lib/m3u8/iso8601/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4efd455047eddeff32bc2a390297c2913a743fde --- /dev/null +++ b/lib/m3u8/iso8601/__init__.py @@ -0,0 +1,2 @@ +# pylama:ignore=W0401,W0611 +from .iso8601 import * diff --git a/lib/m3u8/iso8601/iso8601.py b/lib/m3u8/iso8601/iso8601.py new file mode 100644 index 0000000000000000000000000000000000000000..7829290ecaca8aec85e85b1b4935e1aa9c7b5b18 --- /dev/null +++ b/lib/m3u8/iso8601/iso8601.py @@ -0,0 +1,153 @@ +"""ISO 8601 date time string parsing + +Basic usage: +>>> import iso8601 +>>> iso8601.parse_date("2007-01-25T12:00:00Z") +datetime.datetime(2007, 1, 25, 12, 0, tzinfo=) +>>> + +""" + +import datetime +import re +import typing +from decimal import Decimal + +__all__ = ["parse_date", "ParseError", "UTC", "FixedOffset"] + +# Adapted from http://delete.me.uk/2005/03/iso8601.html +ISO8601_REGEX = re.compile( + r""" + (?P[0-9]{4}) + ( + ( + (-(?P[0-9]{1,2})) + | + (?P[0-9]{2}) + (?!$) # Don't allow YYYYMM + ) + ( + ( + (-(?P[0-9]{1,2})) + | + (?P[0-9]{2}) + ) + ( + ( + (?P[ T]) + (?P[0-9]{2}) + (:{0,1}(?P[0-9]{2})){0,1} + ( + :{0,1}(?P[0-9]{1,2}) + ([.,](?P[0-9]+)){0,1} + ){0,1} + (?P + Z + | + R + | + ( + (?P[-+]) + (?P[0-9]{2}) + :{0,1} + (?P[0-9]{2}){0,1} + ) + ){0,1} + ){0,1} + ) + ){0,1} # YYYY-MM + ){0,1} # YYYY only + $ + """, + re.VERBOSE, +) + + +class ParseError(ValueError): + """Raised when there is a problem parsing a date string""" + + +UTC = datetime.timezone.utc + + +def FixedOffset( + offset_hours: float, offset_minutes: float, name: str +) -> datetime.timezone: + return datetime.timezone( + datetime.timedelta(hours=offset_hours, minutes=offset_minutes), name + ) + + +def parse_timezone( + matches: typing.Dict[str, str], + default_timezone: typing.Optional[datetime.timezone] = UTC, +) -> typing.Optional[datetime.timezone]: + """Parses ISO 8601 time zone specs into tzinfo offsets""" + tz = matches.get("timezone", None) + if tz == "Z": + return UTC + elif tz == "R": + return FixedOffset(5, 0, "-05:00") + # This isn't strictly correct, but it's common to encounter dates without + # timezones so I'll assume the default (which defaults to UTC). + # Addresses issue 4. + if tz is None: + return default_timezone + sign = matches.get("tz_sign", None) + hours = int(matches.get("tz_hour", 0)) + minutes = int(matches.get("tz_minute", 0)) + description = f"{sign}{hours:02d}:{minutes:02d}" + if sign == "-": + hours = -hours + minutes = -minutes + return FixedOffset(hours, minutes, description) + + +def parse_date( + datestring: str, default_timezone: typing.Optional[datetime.timezone] = UTC +) -> datetime.datetime: + """Parses ISO 8601 dates into datetime objects + + The timezone is parsed from the date string. However it is quite common to + have dates without a timezone (not strictly correct). In this case the + default timezone specified in default_timezone is used. This is UTC by + default. + + :param datestring: The date to parse as a string + :param default_timezone: A datetime tzinfo instance to use when no timezone + is specified in the datestring. If this is set to + None then a naive datetime object is returned. + :returns: A datetime.datetime instance + :raises: ParseError when there is a problem parsing the date or + constructing the datetime instance. + + """ + try: + m = ISO8601_REGEX.match(datestring) + except Exception as e: + raise ParseError(e) + + if not m: + raise ParseError(f"Unable to parse date string {datestring!r}") + + # Drop any Nones from the regex matches + # TODO: check if there's a way to omit results in regexes + groups: typing.Dict[str, str] = { + k: v for k, v in m.groupdict().items() if v is not None + } + + try: + return datetime.datetime( + year=int(groups.get("year", 0)), + month=int(groups.get("month", groups.get("monthdash", 1))), + day=int(groups.get("day", groups.get("daydash", 1))), + hour=int(groups.get("hour", 0)), + minute=int(groups.get("minute", 0)), + second=int(groups.get("second", 0)), + microsecond=int( + Decimal(f"0.{groups.get('second_fraction', 0)}") * Decimal("1000000.0") + ), + tzinfo=parse_timezone(groups, default_timezone=default_timezone), + ) + except Exception as e: + raise ParseError(e) diff --git a/lib/m3u8/mixins.py b/lib/m3u8/mixins.py new file mode 100644 index 0000000000000000000000000000000000000000..f6ea07fc671a3dc87668d4c3273a3550a8380919 --- /dev/null +++ b/lib/m3u8/mixins.py @@ -0,0 +1,62 @@ + +import os +from .parser import is_url + +try: + import urlparse as url_parser +except ImportError: + import urllib.parse as url_parser + + +def _urijoin(base_uri, path): + if is_url(base_uri): + return url_parser.urljoin(base_uri, path) + else: + return os.path.normpath(os.path.join(base_uri, path.strip('/'))) + + +class BasePathMixin(object): + + @property + def absolute_uri(self): + if self.uri is None: + return None + if is_url(self.uri): + return self.uri + else: + if self.base_uri is None: + raise ValueError('There can not be `absolute_uri` with no `base_uri` set') + return _urijoin(self.base_uri, self.uri) + + @property + def base_path(self): + if self.uri is None: + return None + return os.path.dirname(self.get_path_from_uri()) + + def get_path_from_uri(self): + """Some URIs have a slash in the query string.""" + return self.uri.split("?")[0] + + @base_path.setter + def base_path(self, newbase_path): + if self.uri is not None: + if not self.base_path: + self.uri = "%s/%s" % (newbase_path, self.uri) + else: + self.uri = self.uri.replace(self.base_path, newbase_path) + + +class GroupedBasePathMixin(object): + + def _set_base_uri(self, new_base_uri): + for item in self: + item.base_uri = new_base_uri + + base_uri = property(None, _set_base_uri) + + def _set_base_path(self, newbase_path): + for item in self: + item.base_path = newbase_path + + base_path = property(None, _set_base_path) diff --git a/lib/m3u8/model.py b/lib/m3u8/model.py new file mode 100644 index 0000000000000000000000000000000000000000..6377c696923418fa2be884cc67a729960a073324 --- /dev/null +++ b/lib/m3u8/model.py @@ -0,0 +1,1304 @@ +# coding: utf-8 +# Copyright 2014 Globo.com Player authors. All rights reserved. +# Use of this source code is governed by a MIT License +# license that can be found in the LICENSE file. + +# ROCKY NOTE: THIS IS A VERSION WITH FIXES FROM A PR. +# NOT SURE IF THE AURTHOR WILL EVER MAKE THE REQUIRED CHANGES +# TO SUPPORT M3U FILES, BUT THIS PR DOES. + +import decimal +import os +import errno +import math +import logging + +from .protocol import ext_x_start, ext_x_key, ext_x_session_key, ext_x_map, extgrp, extvlcopt +from .parser import parse, format_date_time +from .mixins import BasePathMixin, GroupedBasePathMixin + + +class MalformedPlaylistError(Exception): + pass + + +class M3U8(object): + ''' + Represents a single M3U8 playlist. Should be instantiated with + the content as string. + + Parameters: + + `content` + the m3u8 content as string + + `base_path` + all urls (key and segments url) will be updated with this base_path, + ex.: + base_path = "http://videoserver.com/hls" + + /foo/bar/key.bin --> http://videoserver.com/hls/key.bin + http://vid.com/segment1.ts --> http://videoserver.com/hls/segment1.ts + + can be passed as parameter or setted as an attribute to ``M3U8`` object. + `base_uri` + uri the playlist comes from. it is propagated to SegmentList and Key + ex.: http://example.com/path/to + + Attributes: + + `keys` + Returns the list of `Key` objects used to encrypt the segments from m3u8. + It covers the whole list of possible situations when encryption either is + used or not. + + 1. No encryption. + `keys` list will only contain a `None` element. + + 2. Encryption enabled for all segments. + `keys` list will contain the key used for the segments. + + 3. No encryption for first element(s), encryption is applied afterwards + `keys` list will contain `None` and the key used for the rest of segments. + + 4. Multiple keys used during the m3u8 manifest. + `keys` list will contain the key used for each set of segments. + + `session_keys` + Returns the list of `SessionKey` objects used to encrypt multiple segments from m3u8. + + `segments` + a `SegmentList` object, represents the list of `Segment`s from this playlist + + `is_variant` + Returns true if this M3U8 is a variant playlist, with links to + other M3U8s with different bitrates. + + If true, `playlists` is a list of the playlists available, + and `iframe_playlists` is a list of the i-frame playlists available. + + `is_endlist` + Returns true if EXT-X-ENDLIST tag present in M3U8. + http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.8 + + `playlists` + If this is a variant playlist (`is_variant` is True), returns a list of + Playlist objects + + `iframe_playlists` + If this is a variant playlist (`is_variant` is True), returns a list of + IFramePlaylist objects + + `playlist_type` + A lower-case string representing the type of the playlist, which can be + one of VOD (video on demand) or EVENT. + + `media` + If this is a variant playlist (`is_variant` is True), returns a list of + Media objects + + `target_duration` + Returns the EXT-X-TARGETDURATION as an integer + http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.2 + + `media_sequence` + Returns the EXT-X-MEDIA-SEQUENCE as an integer + http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.3 + + `program_date_time` + Returns the EXT-X-PROGRAM-DATE-TIME as a string + http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5 + + `version` + Return the EXT-X-VERSION as is + + `allow_cache` + Return the EXT-X-ALLOW-CACHE as is + + `files` + Returns an iterable with all files from playlist, in order. This includes + segments and key uri, if present. + + `base_uri` + It is a property (getter and setter) used by + SegmentList and Key to have absolute URIs. + + `is_i_frames_only` + Returns true if EXT-X-I-FRAMES-ONLY tag present in M3U8. + http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.12 + + `is_independent_segments` + Returns true if EXT-X-INDEPENDENT-SEGMENTS tag present in M3U8. + https://tools.ietf.org/html/draft-pantos-http-live-streaming-13#section-3.4.16 + + ''' + + simple_attributes = ( + # obj attribute # parser attribute + ('is_variant', 'is_variant'), + ('is_endlist', 'is_endlist'), + ('is_i_frames_only', 'is_i_frames_only'), + ('target_duration', 'targetduration'), + ('media_sequence', 'media_sequence'), + ('program_date_time', 'program_date_time'), + ('is_independent_segments', 'is_independent_segments'), + ('version', 'version'), + ('allow_cache', 'allow_cache'), + ('playlist_type', 'playlist_type'), + ('discontinuity_sequence', 'discontinuity_sequence') + ) + + def __init__(self, content=None, base_path=None, base_uri=None, strict=False, custom_tags_parser=None): + if content is not None: + self.data = parse(content, strict, custom_tags_parser) + else: + self.data = {} + self._base_uri = base_uri + if self._base_uri: + if not self._base_uri.endswith('/'): + self._base_uri += '/' + + self._initialize_attributes() + self.base_path = base_path + + + def _initialize_attributes(self): + self.keys = [ Key(base_uri=self.base_uri, **params) if params else None + for params in self.data.get('keys', []) ] + self.segments = SegmentList([ Segment(base_uri=self.base_uri, keyobject=find_key(segment.get('key', {}), self.keys), **segment) + for segment in self.data.get('segments', []) ]) + #self.keys = get_uniques([ segment.key for segment in self.segments ]) + for attr, param in self.simple_attributes: + setattr(self, attr, self.data.get(param)) + + self.files = [] + for key in self.keys: + # Avoid None key, it could be the first one, don't repeat them + if key and key.uri not in self.files: + self.files.append(key.uri) + self.files.extend(self.segments.uri) + + self.media = MediaList([ Media(base_uri=self.base_uri, **media) + for media in self.data.get('media', []) ]) + + self.playlists = PlaylistList([ Playlist(base_uri=self.base_uri, media=self.media, **playlist) + for playlist in self.data.get('playlists', []) ]) + + self.iframe_playlists = PlaylistList() + for ifr_pl in self.data.get('iframe_playlists', []): + self.iframe_playlists.append(IFramePlaylist(base_uri=self.base_uri, + uri=ifr_pl['uri'], + iframe_stream_info=ifr_pl['iframe_stream_info']) + ) + self.segment_map = self.data.get('segment_map') + + start = self.data.get('start', None) + self.start = start and Start(**start) + + server_control = self.data.get('server_control', None) + self.server_control = server_control and ServerControl(**server_control) + + part_inf = self.data.get('part_inf', None) + self.part_inf = part_inf and PartInformation(**part_inf) + + skip = self.data.get('skip', None) + self.skip = skip and Skip(**skip) + + self.rendition_reports = RenditionReportList([ RenditionReport(base_uri=self.base_uri, **rendition_report) + for rendition_report in self.data.get('rendition_reports', []) ]) + + self.session_data = SessionDataList([ SessionData(**session_data) + for session_data in self.data.get('session_data', []) + if 'data_id' in session_data ]) + + self.session_keys = [ SessionKey(base_uri=self.base_uri, **params) if params else None + for params in self.data.get('session_keys', []) ] + + preload_hint = self.data.get('preload_hint', None) + self.preload_hint = preload_hint and PreloadHint(base_uri=self.base_uri, **preload_hint) + + def __unicode__(self): + return self.dumps() + + @property + def base_uri(self): + return self._base_uri + + @base_uri.setter + def base_uri(self, new_base_uri): + self._base_uri = new_base_uri + self.media.base_uri = new_base_uri + self.playlists.base_uri = new_base_uri + self.iframe_playlists.base_uri = new_base_uri + self.segments.base_uri = new_base_uri + self.rendition_reports.base_uri = new_base_uri + for key in self.keys: + if key: + key.base_uri = new_base_uri + for key in self.session_keys: + if key: + key.base_uri = new_base_uri + if self.preload_hint: + self.preload_hint.base_uri = new_base_uri + + @property + def base_path(self): + return self._base_path + + @base_path.setter + def base_path(self, newbase_path): + self._base_path = newbase_path + self._update_base_path() + + def _update_base_path(self): + if self._base_path is None: + return + for key in self.keys: + if key: + key.base_path = self._base_path + for key in self.session_keys: + if key: + key.base_path = self._base_path + self.media.base_path = self._base_path + self.segments.base_path = self._base_path + self.playlists.base_path = self._base_path + self.iframe_playlists.base_path = self._base_path + self.rendition_reports.base_path = self._base_path + if self.preload_hint: + self.preload_hint.base_path = self._base_path + + + def add_playlist(self, playlist): + self.is_variant = True + self.playlists.append(playlist) + + def add_iframe_playlist(self, iframe_playlist): + if iframe_playlist is not None: + self.is_variant = True + self.iframe_playlists.append(iframe_playlist) + + def add_media(self, media): + self.media.append(media) + + def add_segment(self, segment): + self.segments.append(segment) + + def add_rendition_report(self, report): + self.rendition_reports.append(report) + + def dumps(self): + ''' + Returns the current m3u8 as a string. + You could also use unicode() or str() + ''' + output = ['#EXTM3U'] + if self.is_independent_segments: + output.append('#EXT-X-INDEPENDENT-SEGMENTS') + if self.media_sequence: + output.append('#EXT-X-MEDIA-SEQUENCE:' + str(self.media_sequence)) + if self.discontinuity_sequence: + output.append('#EXT-X-DISCONTINUITY-SEQUENCE:{}'.format( + number_to_string(self.discontinuity_sequence))) + if self.allow_cache: + output.append('#EXT-X-ALLOW-CACHE:' + self.allow_cache.upper()) + if self.version: + output.append('#EXT-X-VERSION:' + str(self.version)) + if self.target_duration: + output.append('#EXT-X-TARGETDURATION:' + + number_to_string(self.target_duration)) + if not (self.playlist_type is None or self.playlist_type == ''): + output.append('#EXT-X-PLAYLIST-TYPE:%s' % str(self.playlist_type).upper()) + if self.start: + output.append(str(self.start)) + if self.is_i_frames_only: + output.append('#EXT-X-I-FRAMES-ONLY') + if self.server_control: + output.append(str(self.server_control)) + if self.is_variant: + if self.media: + output.append(str(self.media)) + output.append(str(self.playlists)) + if self.iframe_playlists: + output.append(str(self.iframe_playlists)) + if self.part_inf: + output.append(str(self.part_inf)) + if self.skip: + output.append(str(self.skip)) + if self.session_data: + output.append(str(self.session_data)) + + for key in self.session_keys: + output.append(str(key)) + + output.append(str(self.segments)) + + if self.preload_hint: + output.append(str(self.preload_hint)) + + if self.rendition_reports: + output.append(str(self.rendition_reports)) + + if self.is_endlist: + output.append('#EXT-X-ENDLIST') + + # ensure that the last line is terminated correctly + if output[-1] and not output[-1].endswith('\n'): + output.append('') + + return '\n'.join(output) + + def dump(self, filename): + ''' + Saves the current m3u8 to ``filename`` + ''' + self._create_sub_directories(filename) + + with open(filename, 'w') as fileobj: + fileobj.write(self.dumps()) + + def _create_sub_directories(self, filename): + basename = os.path.dirname(filename) + try: + if basename: + os.makedirs(basename) + except OSError as error: + if error.errno != errno.EEXIST: + raise + + +class Segment(BasePathMixin): + ''' + A video segment from a M3U8 playlist + + `uri` + a string with the segment uri + + `title` + title attribute from EXTINF parameter + + `program_date_time` + Returns the EXT-X-PROGRAM-DATE-TIME as a datetime. This field is only set + if EXT-X-PROGRAM-DATE-TIME exists for this segment + http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5 + + `current_program_date_time` + Returns a datetime of this segment, either the value of `program_date_time` + when EXT-X-PROGRAM-DATE-TIME is set or a calculated value based on previous + segments' EXT-X-PROGRAM-DATE-TIME and EXTINF values + + `discontinuity` + Returns a boolean indicating if a EXT-X-DISCONTINUITY tag exists + http://tools.ietf.org/html/draft-pantos-http-live-streaming-13#section-3.4.11 + + `cue_out_start` + Returns a boolean indicating if a EXT-X-CUE-OUT tag exists + + `cue_out` + Returns a boolean indicating if a EXT-X-CUE-OUT-CONT tag exists + Note: for backwards compatibility, this will be True when cue_out_start + is True, even though this tag did not exist in the input, and + EXT-X-CUE-OUT-CONT will not exist in the output + + `cue_in` + Returns a boolean indicating if a EXT-X-CUE-IN tag exists + + `scte35` + Base64 encoded SCTE35 metadata if available + + `scte35_duration` + Planned SCTE35 duration + + `duration` + duration attribute from EXTINF parameter + + `base_uri` + uri the key comes from in URI hierarchy. ex.: http://example.com/path/to + + `byterange` + byterange attribute from EXT-X-BYTERANGE parameter + + `key` + Key used to encrypt the segment (EXT-X-KEY) + + `parts` + partial segments that make up this segment + + `dateranges` + any dateranges that should preceed the segment + + `gap_tag` + GAP tag indicates that a Media Segment is missing + ''' + + def __init__(self, uri=None, base_uri=None, program_date_time=None, current_program_date_time=None, + duration=None, title=None, byterange=None, cue_out=False, cue_out_start=False, + cue_in=False, discontinuity=False, key=None, scte35=None, scte35_duration=None, + keyobject=None, parts=None, init_section=None, dateranges=None, gap_tag=None, grp=None, vlcopt=None, + additional_props=None): + self.uri = uri + self.duration = duration + self.title = title + self._base_uri = base_uri + self.byterange = byterange + self.program_date_time = program_date_time + self.current_program_date_time = current_program_date_time + self.discontinuity = discontinuity + self.cue_out_start = cue_out_start + self.cue_out = cue_out + self.cue_in = cue_in + self.scte35 = scte35 + self.scte35_duration = scte35_duration + self.key = keyobject + self.parts = PartialSegmentList( [ PartialSegment(base_uri=self._base_uri, **partial) for partial in parts ] if parts else [] ) + if init_section is not None: + self.init_section = InitializationSection(self._base_uri, **init_section) + else: + self.init_section = None + self.dateranges = DateRangeList( [ DateRange(**daterange) for daterange in dateranges ] if dateranges else [] ) + self.gap_tag = gap_tag + self.grp = grp + self.vlcopt = vlcopt + self.additional_props = additional_props + + # Key(base_uri=base_uri, **key) if key else None + + def add_part(self, part): + self.parts.append(part) + + def dumps(self, last_segment): + output = [] + + if last_segment and self.key != last_segment.key: + output.append(str(self.key)) + output.append('\n') + else: + # The key must be checked anyway now for the first segment + if self.key and last_segment is None: + output.append(str(self.key)) + output.append('\n') + + if last_segment and self.init_section != last_segment.init_section: + if not self.init_section: + raise MalformedPlaylistError( + "init section can't be None if previous is not None") + output.append(str(self.init_section)) + output.append('\n') + else: + if self.init_section and last_segment is None: + output.append(str(self.init_section)) + output.append('\n') + + if self.discontinuity: + output.append('#EXT-X-DISCONTINUITY\n') + if self.program_date_time: + output.append('#EXT-X-PROGRAM-DATE-TIME:%s\n' % + format_date_time(self.program_date_time)) + + if len(self.dateranges): + output.append(str(self.dateranges)) + output.append('\n') + + if self.cue_out_start: + output.append('#EXT-X-CUE-OUT{}\n'.format( + (':' + self.scte35_duration) if self.scte35_duration else '')) + elif self.cue_out: + output.append('#EXT-X-CUE-OUT-CONT\n') + if self.cue_in: + output.append('#EXT-X-CUE-IN\n') + + if self.parts: + output.append(str(self.parts)) + output.append('\n') + + if self.uri: + if self.duration is not None: + props_dumped = '' + if self.additional_props: + props_dumped = ' '.join( + '{0}="{1}"'.format(key, value) + for key, value in self.additional_props.items() + ) + props_dumped = " {0}".format(props_dumped) + + output.append('#EXTINF:%s%s,' % (number_to_string(self.duration), props_dumped)) + if self.title: + output.append(self.title) + output.append('\n') + + if self.byterange: + output.append('#EXT-X-BYTERANGE:%s\n' % self.byterange) + + if self.gap_tag: + output.append('#EXT-X-GAP\n') + + if self.grp: + output.append("{}:{}\n".format(extgrp, self.grp)) + + if self.vlcopt: + for item in self.vlcopt: + output.append("{}:{}\n".format(extvlcopt, item)) + + output.append(self.uri) + + return ''.join(output) + + def __str__(self): + return self.dumps(None) + + @property + def base_path(self): + return super(Segment, self).base_path + + @base_path.setter + def base_path(self, newbase_path): + super(Segment, self.__class__).base_path.fset(self, newbase_path) + self.parts.base_path = newbase_path + if self.init_section is not None: + self.init_section.base_path = newbase_path + + @property + def base_uri(self): + return self._base_uri + + @base_uri.setter + def base_uri(self, newbase_uri): + self._base_uri = newbase_uri + self.parts.base_uri = newbase_uri + if self.init_section is not None: + self.init_section.base_uri = newbase_uri + +class SegmentList(list, GroupedBasePathMixin): + + def __str__(self): + output = [] + last_segment = None + for segment in self: + output.append(segment.dumps(last_segment)) + last_segment = segment + return '\n'.join(output) + + @property + def uri(self): + return [seg.uri for seg in self] + + + def by_key(self, key): + return [ segment for segment in self if segment.key == key ] + + + +class PartialSegment(BasePathMixin): + ''' + A partial segment from a M3U8 playlist + + `uri` + a string with the segment uri + + `program_date_time` + Returns the EXT-X-PROGRAM-DATE-TIME as a datetime. This field is only set + if EXT-X-PROGRAM-DATE-TIME exists for this segment + http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.5 + + `current_program_date_time` + Returns a datetime of this segment, either the value of `program_date_time` + when EXT-X-PROGRAM-DATE-TIME is set or a calculated value based on previous + segments' EXT-X-PROGRAM-DATE-TIME and EXTINF values + + `duration` + duration attribute from EXTINF parameter + + `byterange` + byterange attribute from EXT-X-BYTERANGE parameter + + `independent` + the Partial Segment contains an independent frame + + `gap` + GAP attribute indicates the Partial Segment is not available + + `dateranges` + any dateranges that should preceed the partial segment + + `gap_tag` + GAP tag indicates one or more of the parent Media Segment's Partial + Segments have a GAP=YES attribute. This tag should appear immediately + after the first EXT-X-PART tag in the Parent Segment with a GAP=YES + attribute. + ''' + + def __init__(self, base_uri, uri, duration, program_date_time=None, + current_program_date_time=None, byterange=None, + independent=None, gap=None, dateranges=None, gap_tag=None): + self.base_uri = base_uri + self.uri = uri + self.duration = duration + self.program_date_time = program_date_time + self.current_program_date_time = current_program_date_time + self.byterange = byterange + self.independent = independent + self.gap = gap + self.dateranges = DateRangeList( [ DateRange(**daterange) for daterange in dateranges ] if dateranges else [] ) + self.gap_tag = gap_tag + + def dumps(self, last_segment): + output = [] + + if len(self.dateranges): + output.append(str(self.dateranges)) + output.append('\n') + + if self.gap_tag: + output.append('#EXT-X-GAP\n') + + output.append('#EXT-X-PART:DURATION=%s,URI="%s"' % ( + number_to_string(self.duration), self.uri + )) + + if self.independent: + output.append(',INDEPENDENT=%s' % self.independent) + + if self.byterange: + output.append(',BYTERANGE=%s' % self.byterange) + + if self.gap: + output.append(',GAP=%s' % self.gap) + + return ''.join(output) + + def __str__(self): + return self.dumps(None) + +class PartialSegmentList(list, GroupedBasePathMixin): + + def __str__(self): + output = [str(part) for part in self] + return '\n'.join(output) + +class Key(BasePathMixin): + ''' + Key used to encrypt the segments in a m3u8 playlist (EXT-X-KEY) + + `method` + is a string. ex.: "AES-128" + + `uri` + is a string. ex:: "https://priv.example.com/key.php?r=52" + + `base_uri` + uri the key comes from in URI hierarchy. ex.: http://example.com/path/to + + `iv` + initialization vector. a string representing a hexadecimal number. ex.: 0X12A + + ''' + + tag = ext_x_key + + def __init__(self, method, base_uri, uri=None, iv=None, keyformat=None, keyformatversions=None, **kwargs): + self.method = method + self.uri = uri + self.iv = iv + self.keyformat = keyformat + self.keyformatversions = keyformatversions + self.base_uri = base_uri + self._extra_params = kwargs + + def __str__(self): + output = [ + 'METHOD=%s' % self.method, + ] + if self.uri: + output.append('URI="%s"' % self.uri) + if self.iv: + output.append('IV=%s' % self.iv) + if self.keyformat: + output.append('KEYFORMAT="%s"' % self.keyformat) + if self.keyformatversions: + output.append('KEYFORMATVERSIONS="%s"' % self.keyformatversions) + + return self.tag + ':' + ','.join(output) + + def __eq__(self, other): + if not other: + return False + return self.method == other.method and \ + self.uri == other.uri and \ + self.iv == other.iv and \ + self.base_uri == other.base_uri and \ + self.keyformat == other.keyformat and \ + self.keyformatversions == other.keyformatversions + + def __ne__(self, other): + return not self.__eq__(other) + +class InitializationSection(BasePathMixin): + ''' + Used to obtain Media Initialization Section required to + parse the applicable Media Segments (EXT-X-MAP) + + `uri` + is a string. ex:: "https://priv.example.com/key.php?r=52" + + `byterange` + value of BYTERANGE attribute + + `base_uri` + uri the segment comes from in URI hierarchy. ex.: http://example.com/path/to + ''' + + tag = ext_x_map + + def __init__(self, base_uri, uri, byterange=None): + self.base_uri = base_uri + self.uri = uri + self.byterange = byterange + + def __str__(self): + output = [] + if self.uri: + output.append('URI=' + quoted(self.uri)) + if self.byterange: + output.append('BYTERANGE=' + self.byterange) + return "{tag}:{attributes}".format(tag=self.tag, attributes=",".join(output)) + + def __eq__(self, other): + if not other: + return False + return self.uri == other.uri and \ + self.byterange == other.byterange and \ + self.base_uri == other.base_uri + + def __ne__(self, other): + return not self.__eq__(other) + +class SessionKey(Key): + tag = ext_x_session_key + +class Playlist(BasePathMixin): + ''' + Playlist object representing a link to a variant M3U8 with a specific bitrate. + + Attributes: + + `stream_info` is a named tuple containing the attributes: `program_id`, + `bandwidth`, `average_bandwidth`, `resolution`, `codecs` and `resolution` + which is a a tuple (w, h) of integers + + `media` is a list of related Media entries. + + More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.10 + ''' + + def __init__(self, uri, stream_info, media, base_uri): + self.uri = uri + self.base_uri = base_uri + + resolution = stream_info.get('resolution') + if resolution != None: + resolution = resolution.strip('"') + values = resolution.split('x') + resolution_pair = (int(values[0]), int(values[1])) + else: + resolution_pair = None + + self.stream_info = StreamInfo( + bandwidth=stream_info['bandwidth'], + video=stream_info.get('video'), + audio=stream_info.get('audio'), + subtitles=stream_info.get('subtitles'), + closed_captions=stream_info.get('closed_captions'), + average_bandwidth=stream_info.get('average_bandwidth'), + program_id=stream_info.get('program_id'), + resolution=resolution_pair, + codecs=stream_info.get('codecs'), + frame_rate=stream_info.get('frame_rate'), + video_range=stream_info.get('video_range'), + hdcp_level=stream_info.get('hdcp_level') + ) + self.media = [] + for media_type in ('audio', 'video', 'subtitles'): + group_id = stream_info.get(media_type) + if not group_id: + continue + + self.media += filter(lambda m: m.group_id == group_id, media) + + def __str__(self): + media_types = [] + stream_inf = [str(self.stream_info)] + for media in self.media: + if media.type in media_types: + continue + else: + media_types += [media.type] + media_type = media.type.upper() + stream_inf.append('%s="%s"' % (media_type, media.group_id)) + + return '#EXT-X-STREAM-INF:' + ','.join(stream_inf) + '\n' + self.uri + + +class IFramePlaylist(BasePathMixin): + ''' + IFramePlaylist object representing a link to a + variant M3U8 i-frame playlist with a specific bitrate. + + Attributes: + + `iframe_stream_info` is a named tuple containing the attributes: + `program_id`, `bandwidth`, `average_bandwidth`, `codecs`, `video_range`, + `hdcp_level` and `resolution` which is a tuple (w, h) of integers + + More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.13 + ''' + + def __init__(self, base_uri, uri, iframe_stream_info): + self.uri = uri + self.base_uri = base_uri + + resolution = iframe_stream_info.get('resolution') + if resolution is not None: + values = resolution.split('x') + resolution_pair = (int(values[0]), int(values[1])) + else: + resolution_pair = None + + self.iframe_stream_info = StreamInfo( + bandwidth=iframe_stream_info.get('bandwidth'), + average_bandwidth=iframe_stream_info.get('average_bandwidth'), + video=iframe_stream_info.get('video'), + # Audio, subtitles, and closed captions should not exist in + # EXT-X-I-FRAME-STREAM-INF, so just hardcode them to None. + audio=None, + subtitles=None, + closed_captions=None, + program_id=iframe_stream_info.get('program_id'), + resolution=resolution_pair, + codecs=iframe_stream_info.get('codecs'), + video_range=iframe_stream_info.get('video_range'), + hdcp_level=iframe_stream_info.get('hdcp_level'), + frame_rate=None + ) + + def __str__(self): + iframe_stream_inf = [] + if self.iframe_stream_info.program_id: + iframe_stream_inf.append('PROGRAM-ID=%d' % + self.iframe_stream_info.program_id) + if self.iframe_stream_info.bandwidth: + iframe_stream_inf.append('BANDWIDTH=%d' % + self.iframe_stream_info.bandwidth) + if self.iframe_stream_info.average_bandwidth: + iframe_stream_inf.append('AVERAGE-BANDWIDTH=%d' % + self.iframe_stream_info.average_bandwidth) + if self.iframe_stream_info.resolution: + res = (str(self.iframe_stream_info.resolution[0]) + 'x' + + str(self.iframe_stream_info.resolution[1])) + iframe_stream_inf.append('RESOLUTION=' + res) + if self.iframe_stream_info.codecs: + iframe_stream_inf.append('CODECS=' + + quoted(self.iframe_stream_info.codecs)) + if self.iframe_stream_info.video_range: + iframe_stream_inf.append('VIDEO-RANGE=%s' % + self.iframe_stream_info.video_range) + if self.iframe_stream_info.hdcp_level: + iframe_stream_inf.append('HDCP-LEVEL=%s' % + self.iframe_stream_info.hdcp_level) + if self.uri: + iframe_stream_inf.append('URI=' + quoted(self.uri)) + + return '#EXT-X-I-FRAME-STREAM-INF:' + ','.join(iframe_stream_inf) + + +class StreamInfo(object): + bandwidth = None + closed_captions = None + average_bandwidth = None + program_id = None + resolution = None + codecs = None + audio = None + video = None + subtitles = None + frame_rate = None + video_range = None + hdcp_level = None + + def __init__(self, **kwargs): + self.bandwidth = kwargs.get("bandwidth") + self.closed_captions = kwargs.get("closed_captions") + self.average_bandwidth = kwargs.get("average_bandwidth") + self.program_id = kwargs.get("program_id") + self.resolution = kwargs.get("resolution") + self.codecs = kwargs.get("codecs") + self.audio = kwargs.get("audio") + self.video = kwargs.get("video") + self.subtitles = kwargs.get("subtitles") + self.frame_rate = kwargs.get("frame_rate") + self.video_range = kwargs.get("video_range") + self.hdcp_level = kwargs.get("hdcp_level") + + def __str__(self): + stream_inf = [] + if self.program_id is not None: + stream_inf.append('PROGRAM-ID=%d' % self.program_id) + if self.closed_captions is not None: + stream_inf.append('CLOSED-CAPTIONS=%s' % self.closed_captions) + if self.bandwidth is not None: + stream_inf.append('BANDWIDTH=%d' % self.bandwidth) + if self.average_bandwidth is not None: + stream_inf.append('AVERAGE-BANDWIDTH=%d' % + self.average_bandwidth) + if self.resolution is not None: + res = str(self.resolution[ + 0]) + 'x' + str(self.resolution[1]) + stream_inf.append('RESOLUTION=' + res) + if self.frame_rate is not None: + stream_inf.append('FRAME-RATE=%g' % decimal.Decimal(self.frame_rate).quantize(decimal.Decimal('1.000'))) + if self.codecs is not None: + stream_inf.append('CODECS=' + quoted(self.codecs)) + if self.video_range is not None: + stream_inf.append('VIDEO-RANGE=%s' % self.video_range) + if self.hdcp_level is not None: + stream_inf.append('HDCP-LEVEL=%s' % self.hdcp_level) + return ",".join(stream_inf) + + +class Media(BasePathMixin): + ''' + A media object from a M3U8 playlist + https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-4.3.4.1 + + `uri` + a string with the media uri + + `type` + `group_id` + `language` + `assoc-language` + `name` + `default` + `autoselect` + `forced` + `instream_id` + `characteristics` + `channels` + attributes in the EXT-MEDIA tag + + `base_uri` + uri the media comes from in URI hierarchy. ex.: http://example.com/path/to + ''' + + def __init__(self, uri=None, type=None, group_id=None, language=None, + name=None, default=None, autoselect=None, forced=None, + characteristics=None, channels=None, assoc_language=None, + instream_id=None, base_uri=None, **extras): + self.base_uri = base_uri + self.uri = uri + self.type = type + self.group_id = group_id + self.language = language + self.name = name + self.default = default + self.autoselect = autoselect + self.forced = forced + self.assoc_language = assoc_language + self.instream_id = instream_id + self.characteristics = characteristics + self.channels = channels + self.extras = extras + + def dumps(self): + media_out = [] + + if self.uri: + media_out.append('URI=' + quoted(self.uri)) + if self.type: + media_out.append('TYPE=' + self.type) + if self.group_id: + media_out.append('GROUP-ID=' + quoted(self.group_id)) + if self.language: + media_out.append('LANGUAGE=' + quoted(self.language)) + if self.assoc_language: + media_out.append('ASSOC-LANGUAGE=' + quoted(self.assoc_language)) + if self.name: + media_out.append('NAME=' + quoted(self.name)) + if self.default: + media_out.append('DEFAULT=' + self.default) + if self.autoselect: + media_out.append('AUTOSELECT=' + self.autoselect) + if self.forced: + media_out.append('FORCED=' + self.forced) + if self.instream_id: + media_out.append('INSTREAM-ID=' + quoted(self.instream_id)) + if self.characteristics: + media_out.append('CHARACTERISTICS=' + quoted(self.characteristics)) + if self.channels: + media_out.append('CHANNELS=' + quoted(self.channels)) + + return ('#EXT-X-MEDIA:' + ','.join(media_out)) + + def __str__(self): + return self.dumps() + + +class TagList(list): + + def __str__(self): + output = [str(tag) for tag in self] + return '\n'.join(output) + + +class MediaList(TagList, GroupedBasePathMixin): + + @property + def uri(self): + return [media.uri for media in self] + + +class PlaylistList(TagList, GroupedBasePathMixin): + pass + + +class SessionDataList(TagList): + pass + + +class Start(object): + + def __init__(self, time_offset, precise=None): + self.time_offset = float(time_offset) + self.precise = precise + + def __str__(self): + output = [ + 'TIME-OFFSET=' + str(self.time_offset) + ] + if self.precise and self.precise in ['YES', 'NO']: + output.append('PRECISE=' + str(self.precise)) + + return ext_x_start + ':' + ','.join(output) + +class RenditionReport(BasePathMixin): + def __init__(self, base_uri, uri, last_msn, last_part=None): + self.base_uri = base_uri + self.uri = uri + self.last_msn = last_msn + self.last_part = last_part + + def dumps(self): + report = [] + report.append('URI=' + quoted(self.uri)) + report.append('LAST-MSN=' + number_to_string(self.last_msn)) + if self.last_part is not None: + report.append('LAST-PART=' + number_to_string( + self.last_part)) + + return ('#EXT-X-RENDITION-REPORT:' + ','.join(report)) + + def __str__(self): + return self.dumps() + +class RenditionReportList(list, GroupedBasePathMixin): + + def __str__(self): + output = [str(report) for report in self] + return '\n'.join(output) + +class ServerControl(object): + def __init__(self, can_skip_until=None, can_block_reload=None, + hold_back=None, part_hold_back=None, + can_skip_dateranges=None): + self.can_skip_until = can_skip_until + self.can_block_reload = can_block_reload + self.hold_back = hold_back + self.part_hold_back = part_hold_back + self.can_skip_dateranges = can_skip_dateranges + + def __getitem__(self, item): + return getattr(self, item) + + def dumps(self): + ctrl = [] + if self.can_block_reload: + ctrl.append('CAN-BLOCK-RELOAD=%s' % self.can_block_reload) + + for attr in ['hold_back', 'part_hold_back']: + if self[attr]: + ctrl.append('%s=%s' % ( + denormalize_attribute(attr), + number_to_string(self[attr]) + )) + + if self.can_skip_until: + ctrl.append('CAN-SKIP-UNTIL=%s' % number_to_string( + self.can_skip_until)) + if self.can_skip_dateranges: + ctrl.append('CAN-SKIP-DATERANGES=%s' % + self.can_skip_dateranges) + + return '#EXT-X-SERVER-CONTROL:' + ','.join(ctrl) + + def __str__(self): + return self.dumps() + +class Skip(object): + def __init__(self, skipped_segments, recently_removed_dateranges=None): + self.skipped_segments = skipped_segments + self.recently_removed_dateranges = recently_removed_dateranges + + def dumps(self): + skip = [] + skip.append('SKIPPED-SEGMENTS=%s' % number_to_string( + self.skipped_segments)) + if self.recently_removed_dateranges is not None: + skip.append('RECENTLY-REMOVED-DATERANGES=%s' % + quoted(self.recently_removed_dateranges)) + + return '#EXT-X-SKIP:' + ','.join(skip) + + def __str__(self): + return self.dumps() + +class PartInformation(object): + def __init__(self, part_target=None): + self.part_target = part_target + + def dumps(self): + return '#EXT-X-PART-INF:PART-TARGET=%s' % number_to_string( + self.part_target) + + def __str__(self): + return self.dumps() + +class PreloadHint(BasePathMixin): + def __init__(self, type, base_uri, uri, byterange_start=None, byterange_length=None): + self.hint_type = type + self.base_uri = base_uri + self.uri = uri + self.byterange_start = byterange_start + self.byterange_length = byterange_length + + def __getitem__(self, item): + return getattr(self, item) + + def dumps(self): + hint = [] + hint.append('TYPE=' + self.hint_type) + hint.append('URI=' + quoted(self.uri)) + + for attr in ['byterange_start', 'byterange_length']: + if self[attr] is not None: + hint.append('%s=%s' % ( + denormalize_attribute(attr), + number_to_string(self[attr]) + )) + + return ('#EXT-X-PRELOAD-HINT:' + ','.join(hint)) + + def __str__(self): + return self.dumps() + + +class SessionData(object): + def __init__(self, data_id, value=None, uri=None, language=None): + self.data_id = data_id + self.value = value + self.uri = uri + self.language = language + + def dumps(self): + session_data_out = ['DATA-ID=' + quoted(self.data_id)] + + if self.value: + session_data_out.append('VALUE=' + quoted(self.value)) + elif self.uri: + session_data_out.append('URI=' + quoted(self.uri)) + if self.language: + session_data_out.append('LANGUAGE=' + quoted(self.language)) + + return '#EXT-X-SESSION-DATA:' + ','.join(session_data_out) + + def __str__(self): + return self.dumps() + +class DateRangeList(TagList): + pass + +class DateRange(object): + def __init__(self, **kwargs): + self.id = kwargs['id'] + self.start_date = kwargs.get('start_date') + self.class_ = kwargs.get('class') + self.end_date = kwargs.get('end_date') + self.duration = kwargs.get('duration') + self.planned_duration = kwargs.get('planned_duration') + self.scte35_cmd = kwargs.get('scte35_cmd') + self.scte35_out = kwargs.get('scte35_out') + self.scte35_in = kwargs.get('scte35_in') + self.end_on_next = kwargs.get('end_on_next') + self.x_client_attrs = [ (attr, kwargs.get(attr)) for attr in kwargs if attr.startswith('x_') ] + + def dumps(self): + daterange = [] + daterange.append('ID=' + quoted(self.id)) + + # whilst START-DATE is technically REQUIRED by the spec, this is + # contradicted by an example in the same document (see + # https://tools.ietf.org/html/rfc8216#section-8.10), and also by + # real-world implementations, so we make it optional here + if (self.start_date): + daterange.append('START-DATE=' + quoted(self.start_date)) + if (self.class_): + daterange.append('CLASS=' + quoted(self.class_)) + if (self.end_date): + daterange.append('END-DATE=' + quoted(self.end_date)) + if (self.duration): + daterange.append('DURATION=' + number_to_string(self.duration)) + if (self.planned_duration): + daterange.append('PLANNED-DURATION=' + number_to_string(self.planned_duration)) + if (self.scte35_cmd): + daterange.append('SCTE35-CMD=' + self.scte35_cmd) + if (self.scte35_out): + daterange.append('SCTE35-OUT=' + self.scte35_out) + if (self.scte35_in): + daterange.append('SCTE35-IN=' + self.scte35_in) + if (self.end_on_next): + daterange.append('END-ON-NEXT=' + self.end_on_next) + + # client attributes sorted alphabetically output order is predictable + for attr, value in sorted(self.x_client_attrs): + daterange.append('%s=%s' % ( + denormalize_attribute(attr), + value + )) + + return '#EXT-X-DATERANGE:' + ','.join(daterange) + + def __str__(self): + return self.dumps() + +def find_key(keydata, keylist): + if not keydata: + return None + for key in keylist: + if key: + # Check the intersection of keys and values + if keydata.get('uri', None) == key.uri and \ + keydata.get('method', 'NONE') == key.method and \ + keydata.get('iv', None) == key.iv: + return key + raise KeyError("No key found for key data") + + +def denormalize_attribute(attribute): + return attribute.replace('_', '-').upper() + +def quoted(string): + return '"%s"' % string + + +def number_to_string(number): + with decimal.localcontext() as ctx: + ctx.prec = 20 # set floating point precision + d = decimal.Decimal(str(number)) + return str(d.quantize(decimal.Decimal(1)) if d == d.to_integral_value() else d.normalize()) diff --git a/lib/m3u8/parser.py b/lib/m3u8/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..621ea4f0ef8cc5e8b0c5ff0ba331454f62799383 --- /dev/null +++ b/lib/m3u8/parser.py @@ -0,0 +1,586 @@ +# coding: utf-8 +# Copyright 2014 Globo.com Player authors. All rights reserved. +# Use of this source code is governed by a MIT License +# license that can be found in the LICENSE file. +from . import iso8601 +import datetime +import itertools +import re +from collections import OrderedDict +from . import protocol + +''' +http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.2 +http://stackoverflow.com/questions/2785755/how-to-split-but-ignore-separators-in-quoted-strings-in-python +''' +ATTRIBUTELISTPATTERN = re.compile(r'''((?:[^,"']|"[^"]*"|'[^']*')+)''') + + +def cast_date_time(value): + return iso8601.parse_date(value) + + +def format_date_time(value): + return value.isoformat() + + + +class ParseError(Exception): + + def __init__(self, lineno, line): + self.lineno = lineno + self.line = line + + def __str__(self): + return 'Syntax error in manifest on line %d: %s' % (self.lineno, self.line) + + +def parse(content, strict=False, custom_tags_parser=None): + ''' + Given a M3U8 playlist content returns a dictionary with all data found + ''' + data = { + 'media_sequence': 0, + 'is_variant': False, + 'is_endlist': False, + 'is_i_frames_only': False, + 'is_independent_segments': False, + 'playlist_type': None, + 'playlists': [], + 'segments': [], + 'iframe_playlists': [], + 'media': [], + 'keys': [], + 'rendition_reports': [], + 'skip': {}, + 'part_inf': {}, + 'session_data': [], + 'session_keys': [], + } + + state = { + 'expect_segment': False, + 'expect_playlist': False, + 'current_key': None, + 'current_segment_map': None, + } + + lineno = 0 + for line in string_to_lines(content): + lineno += 1 + line = line.strip() + + if line.startswith(protocol.ext_x_byterange): + _parse_byterange(line, state) + state['expect_segment'] = True + + elif line.startswith(protocol.ext_x_targetduration): + _parse_simple_parameter(line, data, float) + + elif line.startswith(protocol.ext_x_media_sequence): + _parse_simple_parameter(line, data, int) + + elif line.startswith(protocol.ext_x_discontinuity_sequence): + _parse_simple_parameter(line, data, int) + + elif line.startswith(protocol.ext_x_program_date_time): + _, program_date_time = _parse_simple_parameter_raw_value(line, cast_date_time) + if not data.get('program_date_time'): + data['program_date_time'] = program_date_time + state['current_program_date_time'] = program_date_time + state['program_date_time'] = program_date_time + + elif line.startswith(protocol.ext_x_discontinuity): + state['discontinuity'] = True + + elif line.startswith(protocol.ext_x_cue_out_cont): + _parse_cueout_cont(line, state) + state['cue_out'] = True + + elif line.startswith(protocol.ext_x_cue_out): + _parse_cueout(line, state, string_to_lines(content)[lineno - 2]) + state['cue_out_start'] = True + state['cue_out'] = True + + elif line.startswith(protocol.ext_x_cue_in): + state['cue_in'] = True + + elif line.startswith(protocol.ext_x_cue_span): + state['cue_out'] = True + + elif line.startswith(protocol.ext_x_version): + _parse_simple_parameter(line, data, int) + + elif line.startswith(protocol.ext_x_allow_cache): + _parse_simple_parameter(line, data) + + elif line.startswith(protocol.ext_x_key): + key = _parse_key(line) + state['current_key'] = key + if key not in data['keys']: + data['keys'].append(key) + + elif line.startswith(protocol.extinf): + _parse_extinf(line, data, state, lineno, strict) + state['expect_segment'] = True + + elif line.startswith(protocol.extgrp): + _parse_extgrp(line, state) + state['expect_segment'] = True + + elif line.startswith(protocol.extvlcopt): + _parse_extvlcopt(line, state) + state['expect_segment'] = True + + elif line.startswith(protocol.ext_x_stream_inf): + state['expect_playlist'] = True + _parse_stream_inf(line, data, state) + + elif line.startswith(protocol.ext_x_i_frame_stream_inf): + _parse_i_frame_stream_inf(line, data) + + elif line.startswith(protocol.ext_x_media): + _parse_media(line, data, state) + + elif line.startswith(protocol.ext_x_playlist_type): + _parse_simple_parameter(line, data) + + elif line.startswith(protocol.ext_i_frames_only): + data['is_i_frames_only'] = True + + elif line.startswith(protocol.ext_is_independent_segments): + data['is_independent_segments'] = True + + elif line.startswith(protocol.ext_x_endlist): + data['is_endlist'] = True + + elif line.startswith(protocol.ext_x_map): + quoted_parser = remove_quotes_parser('uri') + segment_map_info = _parse_attribute_list(protocol.ext_x_map, line, quoted_parser) + state['current_segment_map'] = segment_map_info + # left for backward compatibility + data['segment_map'] = segment_map_info + + elif line.startswith(protocol.ext_x_start): + attribute_parser = { + "time_offset": lambda x: float(x) + } + start_info = _parse_attribute_list(protocol.ext_x_start, line, attribute_parser) + data['start'] = start_info + + elif line.startswith(protocol.ext_x_server_control): + _parse_server_control(line, data, state) + + elif line.startswith(protocol.ext_x_part_inf): + _parse_part_inf(line, data, state) + + elif line.startswith(protocol.ext_x_rendition_report): + _parse_rendition_report(line, data, state) + + elif line.startswith(protocol.ext_x_part): + _parse_part(line, data, state) + + elif line.startswith(protocol.ext_x_skip): + _parse_skip(line, data, state) + + elif line.startswith(protocol.ext_x_session_data): + _parse_session_data(line, data, state) + + elif line.startswith(protocol.ext_x_session_key): + _parse_session_key(line, data, state) + + elif line.startswith(protocol.ext_x_preload_hint): + _parse_preload_hint(line, data, state) + + elif line.startswith(protocol.ext_x_daterange): + _parse_daterange(line, data, state) + + elif line.startswith(protocol.ext_x_gap): + state['gap'] = True + + # Comments and whitespace + elif line.startswith('#'): + if callable(custom_tags_parser): + custom_tags_parser(line, data, lineno) + + elif line.strip() == '': + # blank lines are legal + pass + + elif state['expect_segment']: + _parse_ts_chunk(line, data, state) + state['expect_segment'] = False + + elif state['expect_playlist']: + _parse_variant_playlist(line, data, state) + state['expect_playlist'] = False + + elif strict: + raise ParseError(lineno, line) + + # there could be remaining partial segments + if 'segment' in state: + data['segments'].append(state.pop('segment')) + + return data + + +def _parse_key(line): + params = ATTRIBUTELISTPATTERN.split(line.replace(protocol.ext_x_key + ':', ''))[1::2] + key = {} + for param in params: + name, value = param.split('=', 1) + key[normalize_attribute(name)] = remove_quotes(value) + return key + + +def _parse_extinf(line, data, state, lineno, strict): + chunks = line.replace(protocol.extinf + ':', '').rsplit(',', 1) + if len(chunks) == 2: + duration_and_props, title = chunks + elif len(chunks) == 1: + if strict: + raise ParseError(lineno, line) + else: + duration_and_props = chunks[0] + title = '' + + additional_props = OrderedDict() + chunks = duration_and_props.strip().split(' ', 1) + if len(chunks) == 2: + duration, raw_props = chunks + matched_props = re.finditer(r'([\w\-]+)="([^"]*)"', raw_props) + for match in matched_props: + additional_props[match.group(1)] = match.group(2) + else: + duration = duration_and_props + + if 'segment' not in state: + state['segment'] = {} + state['segment']['duration'] = float(duration) + state['segment']['title'] = title + state['segment']['additional_props'] = additional_props + + +def _parse_extgrp(line, state): + _, value = _parse_simple_parameter_raw_value(line, str) + if 'segment' not in state: + state['segment'] = {} + state['segment']['grp'] = value + + +def _parse_extvlcopt(line, state): + _, value = _parse_simple_parameter_raw_value(line, str) + if 'segment' not in state: + state['segment'] = {} + if not isinstance(state['segment'].get('vlcopt', None), list): + state['segment']['vlcopt'] = [] + state['segment']['vlcopt'].append(value) + + +def _parse_ts_chunk(line, data, state): + segment = state.pop('segment') + if state.get('program_date_time'): + segment['program_date_time'] = state.pop('program_date_time') + if state.get('current_program_date_time'): + segment['current_program_date_time'] = state['current_program_date_time'] + state['current_program_date_time'] += datetime.timedelta(seconds=segment['duration']) + segment['uri'] = line + segment['cue_in'] = state.pop('cue_in', False) + segment['cue_out'] = state.pop('cue_out', False) + segment['cue_out_start'] = state.pop('cue_out_start', False) + if state.get('current_cue_out_scte35'): + segment['scte35'] = state['current_cue_out_scte35'] + if state.get('current_cue_out_duration'): + segment['scte35_duration'] = state['current_cue_out_duration'] + segment['discontinuity'] = state.pop('discontinuity', False) + if state.get('current_key'): + segment['key'] = state['current_key'] + else: + # For unencrypted segments, the initial key would be None + if None not in data['keys']: + data['keys'].append(None) + if state.get('current_segment_map'): + segment['init_section'] = state['current_segment_map'] + segment['dateranges'] = state.pop('dateranges', None) + segment['gap_tag'] = state.pop('gap', None) + data['segments'].append(segment) + + +def _parse_attribute_list(prefix, line, atribute_parser): + params = ATTRIBUTELISTPATTERN.split(line.replace(prefix + ':', ''))[1::2] + + attributes = {} + for param in params: + name, value = param.split('=', 1) + name = normalize_attribute(name) + + if name in atribute_parser: + value = atribute_parser[name](value) + + attributes[name] = value + + return attributes + +def _parse_stream_inf(line, data, state): + data['is_variant'] = True + data['media_sequence'] = None + atribute_parser = remove_quotes_parser('codecs', 'audio', 'video', 'subtitles', 'closed_captions') + atribute_parser["program_id"] = int + atribute_parser["bandwidth"] = lambda x: int(float(x)) + atribute_parser["average_bandwidth"] = int + atribute_parser["frame_rate"] = float + atribute_parser["video_range"] = str + atribute_parser["hdcp_level"] = str + state['stream_info'] = _parse_attribute_list(protocol.ext_x_stream_inf, line, atribute_parser) + + +def _parse_i_frame_stream_inf(line, data): + atribute_parser = remove_quotes_parser('codecs', 'uri') + atribute_parser["program_id"] = int + atribute_parser["bandwidth"] = int + atribute_parser["average_bandwidth"] = int + atribute_parser["video_range"] = str + atribute_parser["hdcp_level"] = str + iframe_stream_info = _parse_attribute_list(protocol.ext_x_i_frame_stream_inf, line, atribute_parser) + iframe_playlist = {'uri': iframe_stream_info.pop('uri'), + 'iframe_stream_info': iframe_stream_info} + + data['iframe_playlists'].append(iframe_playlist) + + +def _parse_media(line, data, state): + quoted = remove_quotes_parser('uri', 'group_id', 'language', 'assoc_language', 'name', 'instream_id', 'characteristics', 'channels') + media = _parse_attribute_list(protocol.ext_x_media, line, quoted) + data['media'].append(media) + + +def _parse_variant_playlist(line, data, state): + playlist = {'uri': line, + 'stream_info': state.pop('stream_info')} + + data['playlists'].append(playlist) + + +def _parse_byterange(line, state): + if 'segment' not in state: + state['segment'] = {} + state['segment']['byterange'] = line.replace(protocol.ext_x_byterange + ':', '') + + +def _parse_simple_parameter_raw_value(line, cast_to=str, normalize=False): + try: + param, value = line.split(':', 1) + param = normalize_attribute(param.replace('#EXT-X-', '')) + if normalize: + value = value.strip().lower() + return param, cast_to(value) + except ValueError: + # badly formatted msg, return None + return None, None + + +def _parse_and_set_simple_parameter_raw_value(line, data, cast_to=str, normalize=False): + param, value = _parse_simple_parameter_raw_value(line, cast_to, normalize) + if param is None: + return None + data[param] = value + return data[param] + + +def _parse_simple_parameter(line, data, cast_to=str): + return _parse_and_set_simple_parameter_raw_value(line, data, cast_to, True) + + +def _parse_cueout_cont(line, state): + param, value = line.split(':', 1) + res = re.match('.*Duration=(.*),SCTE35=(.*)$', value) + if res: + state['current_cue_out_duration'] = res.group(1) + state['current_cue_out_scte35'] = res.group(2) + +def _cueout_no_duration(line): + # this needs to be called first since line.split in all other + # parsers will throw a ValueError if passed just this tag + if line == protocol.ext_x_cue_out: + return (None, None) + +def _cueout_elemental(line, state, prevline): + param, value = line.split(':', 1) + res = re.match('.*EXT-OATCLS-SCTE35:(.*)$', prevline) + if res: + return (res.group(1), value) + else: + return None + +def _cueout_envivio(line, state, prevline): + param, value = line.split(':', 1) + res = re.match('.*DURATION=(.*),.*,CUE="(.*)"', value) + if res: + return (res.group(2), res.group(1)) + else: + return None + +def _cueout_duration(line): + # this needs to be called after _cueout_elemental + # as it would capture those cues incompletely + # This was added seperately rather than modifying "simple" + param, value = line.split(':', 1) + res = re.match(r'DURATION=(.*)', value) + if res: + return (None, res.group(1)) + +def _cueout_simple(line): + # this needs to be called after _cueout_elemental + # as it would capture those cues incompletely + param, value = line.split(':', 1) + res = re.match(r'^(\d+(?:\.\d)?\d*)$', value) + if res: + return (None, res.group(1)) + +def _parse_cueout(line, state, prevline): + _cueout_state = (_cueout_no_duration(line) + or _cueout_elemental(line, state, prevline) + or _cueout_envivio(line, state, prevline) + or _cueout_duration(line) + or _cueout_simple(line)) + if _cueout_state: + state['current_cue_out_scte35'] = _cueout_state[0] + state['current_cue_out_duration'] = _cueout_state[1] + +def _parse_server_control(line, data, state): + attribute_parser = { + "can_block_reload": str, + "hold_back": lambda x: float(x), + "part_hold_back": lambda x: float(x), + "can_skip_until": lambda x: float(x), + "can_skip_dateranges": str + } + + data['server_control'] = _parse_attribute_list( + protocol.ext_x_server_control, line, attribute_parser + ) + +def _parse_part_inf(line, data, state): + attribute_parser = { + "part_target": lambda x: float(x) + } + + data['part_inf'] = _parse_attribute_list( + protocol.ext_x_part_inf, line, attribute_parser + ) + +def _parse_rendition_report(line, data, state): + attribute_parser = remove_quotes_parser('uri') + attribute_parser['last_msn'] = int + attribute_parser['last_part'] = int + + rendition_report = _parse_attribute_list( + protocol.ext_x_rendition_report, line, attribute_parser + ) + + data['rendition_reports'].append(rendition_report) + +def _parse_part(line, data, state): + attribute_parser = remove_quotes_parser('uri') + attribute_parser['duration'] = lambda x: float(x) + attribute_parser['independent'] = str + attribute_parser['gap'] = str + attribute_parser['byterange'] = str + + part = _parse_attribute_list(protocol.ext_x_part, line, attribute_parser) + + # this should always be true according to spec + if state.get('current_program_date_time'): + part['program_date_time'] = state['current_program_date_time'] + state['current_program_date_time'] += datetime.timedelta(seconds=part['duration']) + + part['dateranges'] = state.pop('dateranges', None) + part['gap_tag'] = state.pop('gap', None) + + if 'segment' not in state: + state['segment'] = {} + segment = state['segment'] + if 'parts' not in segment: + segment['parts'] = [] + + segment['parts'].append(part) + +def _parse_skip(line, data, state): + attribute_parser = remove_quotes_parser('recently_removed_dateranges') + attribute_parser['skipped_segments'] = int + + data['skip'] = _parse_attribute_list(protocol.ext_x_skip, line, attribute_parser) + +def _parse_session_data(line, data, state): + quoted = remove_quotes_parser('data_id', 'value', 'uri', 'language') + session_data = _parse_attribute_list(protocol.ext_x_session_data, line, quoted) + data['session_data'].append(session_data) + +def _parse_session_key(line, data, state): + params = ATTRIBUTELISTPATTERN.split(line.replace(protocol.ext_x_session_key + ':', ''))[1::2] + key = {} + for param in params: + name, value = param.split('=', 1) + key[normalize_attribute(name)] = remove_quotes(value) + data['session_keys'].append(key) + +def _parse_preload_hint(line, data, state): + attribute_parser = remove_quotes_parser('uri') + attribute_parser['type'] = str + attribute_parser['byterange_start'] = int + attribute_parser['byterange_length'] = int + + data['preload_hint'] = _parse_attribute_list( + protocol.ext_x_preload_hint, line, attribute_parser + ) + +def _parse_daterange(line, date, state): + attribute_parser = remove_quotes_parser('id', 'class', 'start_date', 'end_date') + attribute_parser['duration'] = float + attribute_parser['planned_duration'] = float + attribute_parser['end_on_next'] = str + attribute_parser['scte35_cmd'] = str + attribute_parser['scte35_out'] = str + attribute_parser['scte35_in'] = str + + parsed = _parse_attribute_list( + protocol.ext_x_daterange, line, attribute_parser + ) + + if 'dateranges' not in state: + state['dateranges'] = [] + + state['dateranges'].append(parsed) + + +def string_to_lines(string): + return string.strip().splitlines() + + +def remove_quotes_parser(*attrs): + return dict(zip(attrs, itertools.repeat(remove_quotes))) + + +def remove_quotes(string): + ''' + Remove quotes from string. + + Ex.: + "foo" -> foo + 'foo' -> foo + 'foo -> 'foo + + ''' + quotes = ('"', "'") + if string.startswith(quotes) and string.endswith(quotes): + return string[1:-1] + return string + + +def normalize_attribute(attribute): + return attribute.replace('-', '_').lower().strip() + + +def is_url(uri): + return uri.startswith(('https://', 'http://')) diff --git a/lib/m3u8/protocol.py b/lib/m3u8/protocol.py new file mode 100644 index 0000000000000000000000000000000000000000..df34a2070dd213ee1f50aac5b9ca81b0682abfab --- /dev/null +++ b/lib/m3u8/protocol.py @@ -0,0 +1,42 @@ +# coding: utf-8 +# Copyright 2014 Globo.com Player authors. All rights reserved. +# Use of this source code is governed by a MIT License +# license that can be found in the LICENSE file. + +ext_x_targetduration = '#EXT-X-TARGETDURATION' +ext_x_media_sequence = '#EXT-X-MEDIA-SEQUENCE' +ext_x_discontinuity_sequence = '#EXT-X-DISCONTINUITY-SEQUENCE' +ext_x_program_date_time = '#EXT-X-PROGRAM-DATE-TIME' +ext_x_media = '#EXT-X-MEDIA' +ext_x_playlist_type = '#EXT-X-PLAYLIST-TYPE' +ext_x_key = '#EXT-X-KEY' +ext_x_stream_inf = '#EXT-X-STREAM-INF' +ext_x_version = '#EXT-X-VERSION' +ext_x_allow_cache = '#EXT-X-ALLOW-CACHE' +ext_x_endlist = '#EXT-X-ENDLIST' +extinf = '#EXTINF' +ext_i_frames_only = '#EXT-X-I-FRAMES-ONLY' +ext_x_byterange = '#EXT-X-BYTERANGE' +ext_x_i_frame_stream_inf = '#EXT-X-I-FRAME-STREAM-INF' +ext_x_discontinuity = '#EXT-X-DISCONTINUITY' +ext_x_cue_out = '#EXT-X-CUE-OUT' +ext_x_cue_out_cont = '#EXT-X-CUE-OUT-CONT' +ext_x_cue_in = '#EXT-X-CUE-IN' +ext_x_cue_span = '#EXT-X-CUE-SPAN' +ext_x_scte35 = '#EXT-OATCLS-SCTE35' +ext_is_independent_segments = '#EXT-X-INDEPENDENT-SEGMENTS' +ext_x_map = '#EXT-X-MAP' +ext_x_start = '#EXT-X-START' +ext_x_server_control = '#EXT-X-SERVER-CONTROL' +ext_x_part_inf = '#EXT-X-PART-INF' +ext_x_part = '#EXT-X-PART' +ext_x_rendition_report = '#EXT-X-RENDITION-REPORT' +ext_x_skip = '#EXT-X-SKIP' +ext_x_session_data = '#EXT-X-SESSION-DATA' +ext_x_session_key = '#EXT-X-SESSION-KEY' +ext_x_preload_hint = '#EXT-X-PRELOAD-HINT' +ext_x_daterange = "#EXT-X-DATERANGE" +ext_x_gap = "#EXT-X-GAP" + +extgrp = '#EXTGRP' +extvlcopt = '#EXTVLCOPT' diff --git a/lib/main.py b/lib/main.py new file mode 100644 index 0000000000000000000000000000000000000000..880ad73a311e014927de63ab41a5d1f771fbf318 --- /dev/null +++ b/lib/main.py @@ -0,0 +1,291 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import gc +import argparse +import logging +import os +import platform +import sys +import time +from multiprocessing import Queue, Process + + +try: + from pip._internal import main as pip + try: + import cryptography + except ImportError: + pip(['install', 'cryptography']) + except ModuleNotFoundError: + print('Unable to install required cryptography module') + try: + import httpx + except ImportError: + pip(['install', 'httpx[http2]']) + except ModuleNotFoundError: + print('Unable to install required httpx[http2] module') +except (ImportError, ModuleNotFoundError): + print('Unable to load pip module to install required modules') + + + +import lib.clients.hdhr.hdhr_server as hdhr_server +import lib.clients.web_tuner as web_tuner +import lib.clients.web_admin as web_admin +import lib.common.utils as utils +import lib.plugins.plugin_handler as plugin_handler +import lib.clients.ssdp.ssdp_server as ssdp_server +import lib.db.datamgmt.backups as backups +import lib.updater.updater as updater +import lib.config.user_config as user_config +from lib.db.db_scheduler import DBScheduler +from lib.db.db_temp import DBTemp +from lib.common.utils import clean_exit +from lib.common.pickling import Pickling +from lib.schedule.scheduler import Scheduler +from lib.common.decorators import getrequest +from lib.web.pages.templates import web_templates +import lib.updater.patcher as patcher +from lib.streams.stream import Stream + + +RESTART_REQUESTED = None +LOGGER = None + +if sys.version_info.major == 2 or sys.version_info < (3, 7): + print('Error: Cabernet requires python 3.7+.') + sys.exit(1) + + +def get_args(): + parser = argparse.ArgumentParser(description='Fetch online streams', epilog='') + parser.add_argument('-c', '--config_file', dest='cfg', type=str, default=None, help='config.ini location') + parser.add_argument('-r', '--restart', help='Restart process') + return parser.parse_args() + + +def restart_cabernet(_plugins): + global RESTART_REQUESTED + RESTART_REQUESTED = True + while RESTART_REQUESTED: + time.sleep(0.10) + return True + + +@getrequest.route('/api/restart') +def restart_api(_webserver): + scheduler_db = DBScheduler(_webserver.config) + tasks = scheduler_db.get_tasks('Applications', 'Restart') + if len(tasks) == 1: + _webserver.sched_queue.put({'cmd': 'runtask', 'taskid': tasks[0]['taskid'] }) + _webserver.do_mime_response(200, 'text/html', 'Restarting Cabernet') + else: + _webserver.do_mime_response(404, 'text/html', web_templates['htmlError'].format('404 - Request Not Found')) + + +def main(script_dir): + """ main startup method for app """ + global RESTART_REQUESTED + global LOGGER + hdhr_serverx = None + ssdp_serverx = None + webadmin = None + tuner = None + + # Gather args + args = get_args() + + # Get Operating system + opersystem = platform.system() + config_obj = None + scheduler = None + terminate_queue = None + try: + RESTART_REQUESTED = False + + config_obj = user_config.get_config(script_dir, opersystem, args) + config = config_obj.data + LOGGER = logging.getLogger(__name__) + # reduce logging for httpx modules + logging.getLogger("hpack").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + LOGGER.warning('#########################################') + LOGGER.warning('MIT License, Copyright (C) 2021 ROCKY4546') + LOGGER.notice('Cabernet v{}'.format(utils.get_version_str())) + except KeyboardInterrupt: + if LOGGER: + LOGGER.warning('^C received, shutting down the server') + return + + try: + # use this until 0.9.3 due to maintenance mode not being enabled in 0.9.1 + if config['main']['maintenance_mode']: + LOGGER.info('In maintenance mode, applying patches') + patcher.patch_upgrade(config_obj, utils.VERSION) + time.sleep(0.01) + config_obj.write('main', 'maintenance_mode', False) + + utils.cleanup_web_temp(config) + dbtemp = DBTemp(config) + dbtemp.cleanup_temp(None, None) + plugins = init_plugins(config_obj) + config_obj.defn_json = None + init_versions(plugins) + + if opersystem in ['Windows']: + pickle_it = Pickling(config) + pickle_it.to_pickle(plugins) + + backups.scheduler_tasks(config) + terminate_queue = Queue() + hdhr_queue = Queue() + sched_queue = Queue() + webadmin = init_webadmin(config, plugins, hdhr_queue, terminate_queue, sched_queue) + tuner = init_tuner(config, plugins, hdhr_queue, terminate_queue) + scheduler = init_scheduler(config, plugins, sched_queue) + time.sleep(0.1) + ssdp_serverx = init_ssdp(config) + hdhr_serverx = init_hdhr(config, hdhr_queue) + + if opersystem in ['Windows']: + time.sleep(2) + pickle_it.delete_pickle(plugins.__class__.__name__) + LOGGER.notice('Cabernet is now online.') + + RESTART_REQUESTED = False + while not RESTART_REQUESTED: + time.sleep(5) + terminate_queue.put('shutdown') + LOGGER.notice('Shutting Down and Restarting...') + RESTART_REQUESTED = False + time.sleep(3) + terminate_processes(config, hdhr_serverx, ssdp_serverx, webadmin, tuner, scheduler, config_obj) + + except KeyboardInterrupt: + if LOGGER: + LOGGER.warning('^C received, shutting down the server') + shutdown(config, hdhr_serverx, ssdp_serverx, webadmin, tuner, scheduler, config_obj, terminate_queue) + + +def scheduler_tasks(_config): + scheduler_db = DBScheduler(_config) + scheduler_db.save_task( + 'Applications', + 'Restart', + 'internal', + None, + 'lib.main.restart_cabernet', + 20, + 'inline', + 'Restarts Cabernet' + ) + + +def init_plugins(_config_obj): + LOGGER.info('Getting Plugins...') + plugins = plugin_handler.PluginHandler(_config_obj) + plugins.initialize_plugins() + return plugins + + +def init_versions(_plugins): + updater_obj = updater.Updater(_plugins) + updater_obj.scheduler_tasks() + + +def init_webadmin(_config, _plugins, _hdhr_queue, _terminate_queue, _sched_queue): + LOGGER.notice('Starting admin website on {}:{}'.format( + _config['web']['plex_accessible_ip'], + _config['web']['web_admin_port'])) + webadmin = Process(target=web_admin.start, args=(_plugins, _hdhr_queue, _terminate_queue, _sched_queue)) + webadmin.start() + time.sleep(0.1) + return webadmin + + +def init_tuner(_config, _plugins, _hdhr_queue, _terminate_queue): + LOGGER.notice('Starting streaming tuner website on {}:{}'.format( + _config['web']['plex_accessible_ip'], + _config['web']['plex_accessible_port'])) + tuner = Process(target=web_tuner.start, args=(_plugins, _hdhr_queue, _terminate_queue,)) + tuner.start() + time.sleep(0.1) + return tuner + +def init_scheduler(_config, _plugins, _sched_queue): + scheduler_tasks(_config) + return Scheduler(_plugins, _sched_queue) + + +def init_ssdp(_config): + if not _config['ssdp']['disable_ssdp']: + LOGGER.notice('Starting SSDP service on port 1900') + ssdp_serverx = Process(target=ssdp_server.ssdp_process, args=(_config,)) + ssdp_serverx.daemon = True + ssdp_serverx.start() + time.sleep(0.1) + return ssdp_serverx + return None + + +def init_hdhr(_config, _hdhr_queue): + if not _config['hdhomerun']['disable_hdhr']: + LOGGER.notice('Starting HDHR service on port 65001') + hdhr_serverx = Process(target=hdhr_server.hdhr_process, args=(_config, _hdhr_queue,)) + hdhr_serverx.start() + time.sleep(0.1) + return hdhr_serverx + return None + + +def shutdown(_config, _hdhr_serverx, _ssdp_serverx, _webadmin, _tuner, _scheduler, _config_obj, _terminate_queue): + if _terminate_queue: + _terminate_queue.put('shutdown') + time.sleep(0.01) + terminate_processes(_config, _hdhr_serverx, _ssdp_serverx, _webadmin, _tuner, _scheduler, _config_obj) + LOGGER.debug('main process terminated') + clean_exit() + + +def terminate_processes(_config, _hdhr_serverx, _ssdp_serverx, _webadmin, _tuner, _scheduler, _config_obj): + if not _config['hdhomerun']['disable_hdhr'] and _hdhr_serverx: + _hdhr_serverx.terminate() + _hdhr_serverx.join() + del _hdhr_serverx + if not _config['ssdp']['disable_ssdp'] and _ssdp_serverx: + _ssdp_serverx.terminate() + _ssdp_serverx.join() + del _ssdp_serverx + if _scheduler: + _scheduler.terminate() + del _scheduler + if _webadmin: + _webadmin.terminate() + _webadmin.join() + del _webadmin + if _tuner: + _tuner.terminate() + _tuner.join() + del _tuner + if _config_obj and _config_obj.defn_json: + _config_obj.defn_json.terminate() + del _config_obj + time.sleep(0.5) \ No newline at end of file diff --git a/lib/plugins/__init__.py b/lib/plugins/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..87d95d7d1f6e8c7130a79051c731c45b53a4545c --- /dev/null +++ b/lib/plugins/__init__.py @@ -0,0 +1 @@ +import lib.plugins.plugin_manager \ No newline at end of file diff --git a/lib/plugins/plugin.py b/lib/plugins/plugin.py new file mode 100644 index 0000000000000000000000000000000000000000..c91d97d26f69830b6bf90802ade4151d19b5db45 --- /dev/null +++ b/lib/plugins/plugin.py @@ -0,0 +1,207 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import json +import importlib +import importlib.resources +import lib.common.utils as utils + +import lib.common.exceptions as exceptions +from lib.config.config_defn import ConfigDefn +from lib.db.db_plugins import DBPlugins +from lib.db.db_config_defn import DBConfigDefn + +PLUGIN_CONFIG_DEFN_FILE = 'config_defn.json' +PLUGIN_INSTANCE_DEFN_FILE = 'instance_defn.json' +PLUGIN_MANIFEST_FILE = 'plugin.json' + + +def register(func): + """Decorator for registering a new plugin""" + Plugin._plugin_func = func + return func + + +class Plugin: + # Temporarily used to register the plugin setup() function + _plugin_func = None + logger = None + + def __init__(self, _config_obj, _plugin_defn, _plugins_pkg, _plugin_id, _is_external): + if Plugin.logger is None: + Plugin.logger = logging.getLogger(__name__) + self.enabled = True + self.plugin_path = '.'.join([_plugins_pkg, _plugin_id]) + self.plugin_id = _plugin_id + self.config_obj = _config_obj + self.db_configdefn = DBConfigDefn(_config_obj.data) + self.load_config_defn() + + # plugin is registered after this call, so grab reg data + self.init_func = Plugin._plugin_func + self.plugin_settings = {} + self.plugin_db = DBPlugins(_config_obj.data) + self.namespace = '' + self.instances = [] + self.repo_id = None + self.load_plugin_manifest(_plugin_defn, _is_external) + if not self.namespace: + self.enabled = False + self.logger.debug('1 Plugin disabled in config.ini for {}'.format(self.namespace)) + return + self.plugin_obj = None + self.config_obj.data[self.namespace.lower()]['version'] = self.plugin_settings['version']['current'] + if not self.config_obj.data[self.namespace.lower()].get('enabled'): + self.enabled = False + self.logger.debug('2 Plugin disabled in config.ini for {}'.format(self.namespace)) + self.db_configdefn.add_config(self.config_obj.data) + return + self.load_instances() + self.logger.notice('Plugin created for {}'.format(self.namespace)) + + def terminate(self): + """ + Removes all has a object from the object and calls any subclasses to also terminate + Not calling inherited class at this time + """ + self.enabled = False + self.config_obj.write( + self.namespace.lower(), 'enabled', False) + + if self.plugin_obj: + self.plugin_obj.terminate() + self.plugin_path = None + self.plugin_id = None + self.config_obj = None + self.db_configdefn = None + self.init_func = None + self.plugin_settings = None + self.plugin_db = None + self.namespace = None + self.instances = None + self.repo_id = None + self.plugin_obj = None + + + def load_config_defn(self): + try: + self.logger.debug( + 'Plugin Config Defn file loaded at {}'.format(self.plugin_path)) + defn_obj = ConfigDefn(self.plugin_path, PLUGIN_CONFIG_DEFN_FILE, self.config_obj.data) + default_config = defn_obj.get_default_config() + self.config_obj.merge_config(default_config) + defn_obj.call_oninit(self.config_obj) + self.config_obj.defn_json.merge_defn_obj(defn_obj) + for area, area_data in defn_obj.config_defn.items(): + for section, section_data in area_data['sections'].items(): + for setting in section_data['settings'].keys(): + new_value = self.config_obj.fix_value_type( + section, setting, self.config_obj.data[section][setting]) + self.config_obj.data[section][setting] = new_value + self.db_configdefn.add_config(self.config_obj.data) + defn_obj.terminate() + except FileNotFoundError: + self.logger.warning( + 'PLUGIN CONFIG DEFN FILE NOT FOUND AT {}'.format(self.plugin_path)) + + def load_instances(self): + inst_defn_obj = ConfigDefn(self.plugin_path, PLUGIN_INSTANCE_DEFN_FILE, self.config_obj.data, True) + # determine in the config data whether the instance of this name exists. + # It would have a section name = 'name-instance' + self.instances = self.find_instances() + if len(self.instances) == 0: + self.enabled = True + self.config_obj.data[self.namespace.lower()]['enabled'] = True + self.logger.info('No instances found, {}'.format(self.namespace)) + return + for inst in self.instances: + self.plugin_db.save_instance(self.repo_id, self.namespace, inst, '') + # create a defn with the instance name as the section name. then process it. + inst_defn_obj.is_instance_defn = False + for area, area_data in inst_defn_obj.config_defn.items(): + if len(area_data['sections']) != 1: + self.logger.error('INSTANCE MUST HAVE ONE AND ONLY ONE SECTION') + raise exceptions.CabernetException('plugin defn must have one and only one instance section') + section = list(area_data['sections'].keys())[0] + base_section = section.split('_', 1)[0] + area_data['sections'][base_section + '_' + inst] = area_data['sections'].pop(section) + if 'label' in self.config_obj.data[base_section + '_' + inst] \ + and self.config_obj.data[base_section + '_' + inst]['label'] is not None: + area_data['sections'][base_section + '_' + inst]['label'] = \ + self.config_obj.data[base_section + '_' + inst]['label'] + inst_defn_obj.save_defn_to_db() + + default_config = inst_defn_obj.get_default_config() + self.config_obj.merge_config(default_config) + inst_defn_obj.call_oninit(self.config_obj) + self.config_obj.defn_json.merge_defn_obj(inst_defn_obj) + for area2, area_data2 in inst_defn_obj.config_defn.items(): + for section, section_data in area_data2['sections'].items(): + for setting in section_data['settings'].keys(): + new_value = self.config_obj.fix_value_type( + section, setting, self.config_obj.data[section][setting]) + self.config_obj.data[section][setting] = new_value + self.db_configdefn.add_config(self.config_obj.data) + + def find_instances(self): + instances = [] + inst_sec = self.namespace.lower() + '_' + for section in self.config_obj.data.keys(): + if section.startswith(inst_sec): + instances.append(section.split(inst_sec, 1)[1]) + return instances + + def load_plugin_manifest(self, _plugin_defn, _is_external): + self.load_default_settings(_plugin_defn) + self.import_manifest(_is_external) + + def load_default_settings(self, _plugin_defn): + for name, attr in _plugin_defn.items(): + self.plugin_settings[name] = attr['default'] + + def import_manifest(self, _is_external): + try: + json_settings = self.plugin_db.get_plugins(_installed=None, _repo_id=None, _plugin_id=self.plugin_id) + + local_settings = importlib.resources.read_text(self.plugin_path, PLUGIN_MANIFEST_FILE) + local_settings = json.loads(local_settings) + local_settings = local_settings['plugin'] + + if not json_settings: + json_settings = local_settings + json_settings['repoid'] = None + else: + json_settings = json_settings[0] + self.repo_id = json_settings['repoid'] + if local_settings['version']['current']: + json_settings['version']['current'] = local_settings['version']['current'] + json_settings['external'] = _is_external + json_settings['version']['installed'] = True + self.namespace = json_settings['name'] + self.plugin_db.save_plugin(json_settings) + self.logger.debug( + 'Plugin Manifest file loaded at {}'.format(self.plugin_path)) + self.plugin_settings = utils.merge_dict(self.plugin_settings, json_settings, True) + except FileNotFoundError: + self.logger.warning( + 'PLUGIN MANIFEST FILE NOT FOUND AT {}'.format(self.plugin_path)) + + @property + def name(self): + return self.plugin_settings['name'] diff --git a/lib/plugins/plugin_channels.py b/lib/plugins/plugin_channels.py new file mode 100644 index 0000000000000000000000000000000000000000..0f0a7ea52a5f721d6d305295691e837c62898a36 --- /dev/null +++ b/lib/plugins/plugin_channels.py @@ -0,0 +1,306 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import collections +import datetime +import json +import logging +import io +import re +import threading +import time + +import lib.m3u8 as m3u8 +import lib.config.config_callbacks as config_callbacks +import lib.common.utils as utils +import lib.image_size.get_image_size as get_image_size +from lib.db.db_channels import DBChannels +from lib.common.decorators import handle_url_except +from lib.common.decorators import handle_json_except + +class PluginChannels: + + def __init__(self, _instance_obj): + self.logger = logging.getLogger(__name__) + self.instance_obj = _instance_obj + self.config_obj = self.instance_obj.config_obj + self.plugin_obj = _instance_obj.plugin_obj + self.instance_key = _instance_obj.instance_key + self.db = DBChannels(self.config_obj.data) + self.config_section = self.instance_obj.config_section + + self.ch_num_enum = self.config_obj.data[self.config_section].get('channel-start_ch_num') + if self.ch_num_enum is None or self.ch_num_enum < 0: + self.ch_num_enum = 0 + + def terminate(self): + """ + Removes all has a object from the object and calls any subclasses to also terminate + Not calling inherited class at this time + """ + self.logger = None + self.instance_obj = None + self.config_obj = None + self.plugin_obj = None + self.instance_key = None + self.db = None + self.config_section = None + self.ch_num_enum = None + + def set_channel_num(self, _number): + """ + if _number is None then will set the channel number based + on the enum counter + """ + if _number is None: + ch_number = self.ch_num_enum + self.ch_num_enum += 1 + return ch_number + else: + return _number + + def get_channels(self): + """ + Interface method to override + """ + pass + + @handle_url_except() + @handle_json_except + def get_uri_json_data(self, _uri, _retries): + header = { + 'Content-Type': 'application/json', + 'User-agent': utils.DEFAULT_USER_AGENT} + resp = self.plugin_obj.http_session.get(_uri, headers=header, timeout=8) + x = resp.json() + resp.raise_for_status() + return x + + @handle_url_except() + def get_uri_data(self, _uri, _retries, _header=None, _data=None, _cookies=None): + if _header is None: + header = { + 'User-agent': utils.DEFAULT_USER_AGENT} + else: + header = _header + if _data: + resp = self.plugin_obj.http_session.post(_uri, headers=header, data=_data, timeout=18, verify=False, cookies=_cookies) + else: + resp = self.plugin_obj.http_session.get(_uri, headers=header, timeout=18, verify=False, cookies=_cookies) + x = resp.content + return x + + @handle_url_except() + def get_m3u8_data(self, _uri, _retries, _header=None): + if _header is None: + return m3u8.load(_uri, + headers={'User-agent': utils.DEFAULT_USER_AGENT}, + http_session=self.plugin_obj.http_session) + else: + return m3u8.load(_uri, + headers=_header, + http_session=self.plugin_obj.http_session) + + def refresh_channels(self, force=False): + self.ch_num_enum = self.config_obj.data[self.config_section].get('channel-start_ch_num') + if self.ch_num_enum is None or self.ch_num_enum < 0: + self.ch_num_enum = 0 + last_update = self.db.get_status(self.plugin_obj.name, self.instance_key) + update_needed = False + if not last_update: + update_needed = True + else: + delta = datetime.datetime.now() - last_update + if delta.total_seconds() / 3600 >= \ + self.config_obj.data[self.config_section]['channel-update_timeout']: + update_needed = True + if update_needed or force: + i = 0 + ch_dict = self.get_channels() + while ch_dict is None and i < 2: + i += 1 + time.sleep(0.5) + ch_dict = self.get_channels() + if ch_dict is None: + self.logger.warning( + 'Unable to retrieve channel data from {}:{}, aborting refresh' + .format(self.plugin_obj.name, self.instance_key)) + return False + if 'channel-import_groups' in self.config_obj.data[self.config_section]: + self.db.save_channel_list( + self.plugin_obj.name, self.instance_key, ch_dict, + self.config_obj.data[self.config_section]['channel-import_groups']) + else: + self.db.save_channel_list(self.plugin_obj.name, self.instance_key, ch_dict) + if self.config_obj.data[self.config_section].get('channel-start_ch_num') > -1: + config_callbacks.update_channel_num(self.config_obj, self.config_section, 'channel-start_ch_num') + self.logger.debug( + '{}:{} Channel update complete' + .format(self.plugin_obj.name, self.instance_key)) + else: + self.logger.debug( + 'Channel data still new for {} {}, not refreshing' + .format(self.plugin_obj.name, self.instance_key)) + return False + + return True + + def clean_group_name(self, group_name): + return re.sub('[ +&*%$#@!:;,<>?]', '', group_name) + + @handle_url_except() + def get_thumbnail_size(self, _thumbnail, _retries, _ch_uid, ): + thumbnail_size = (0, 0) + if _thumbnail is None or _thumbnail == '': + return thumbnail_size + + if _ch_uid is not None: + ch_row = self.db.get_channel(_ch_uid, self.plugin_obj.name, self.instance_key) + if ch_row is not None: + if ch_row['json']['thumbnail'] == _thumbnail: + return ch_row['json']['thumbnail_size'] + + h = {'User-Agent': utils.DEFAULT_USER_AGENT, + 'Accept': '*/*', + 'Accept-Encoding': 'identity', + 'Connection': 'Keep-Alive' + } + resp = self.plugin_obj.http_session.get(_thumbnail, headers=h, timeout=8) + resp.raise_for_status() + img_blob = resp.content + fp = io.BytesIO(img_blob) + sz = len(img_blob) + try: + thumbnail_size = get_image_size.get_image_size_from_bytesio(fp, sz) + except get_image_size.UnknownImageFormat as e: + self.logger.warning('{}: Thumbnail unknown format. {}' + .format(self.plugin_obj.name, str(e))) + pass + return thumbnail_size + + @handle_url_except + def get_best_stream(self, _url, _retries, _channel_id, _referer=None): + if self.config_obj.data[self.config_section]['player-stream_type'] == 'm3u8redirect': + return _url + + + self.logger.debug( + '{}: Getting best video stream info for {} {}' + .format(self.plugin_obj.name, _channel_id, _url)) + best_stream = None + if _referer: + header = { + 'User-agent': utils.DEFAULT_USER_AGENT, + 'Referer': _referer} + else: + header = {'User-agent': utils.DEFAULT_USER_AGENT} + + ch_dict = self.db.get_channel(_channel_id, self.plugin_obj.name, self.instance_key) + ch_json = ch_dict['json'] + best_resolution = -1 + video_url_m3u = m3u8.load( + _url, headers=header, + http_session=self.plugin_obj.http_session) + + if not video_url_m3u: + self.logger.notice('{}:{} Unable to obtain m3u file, aborting stream {}' + .format(self.plugin_obj.name, self.instance_key, _channel_id)) + return + self.logger.debug("Found " + str(len(video_url_m3u.playlists)) + " Playlists") + + if len(video_url_m3u.playlists) > 0: + max_bitrate = self.config_obj.data[self.config_section]['player-stream_quality'] + bitrate_list = {} + for video_stream in video_url_m3u.playlists: + bitrate_list[video_stream.stream_info.bandwidth] = video_stream + bitrate_list = collections.OrderedDict(sorted(bitrate_list.items(), reverse=True)) + # bitrate is sorted from highest to lowest + if list(bitrate_list.keys())[0] > max_bitrate: + is_set_by_bitrate = True + else: + is_set_by_bitrate = False + for bps, seg in bitrate_list.items(): + if bps < max_bitrate: + best_stream = seg + if seg.stream_info.resolution: + best_resolution = seg.stream_info.resolution[1] + break + else: + best_stream = seg + if seg.stream_info.resolution: + best_resolution = seg.stream_info.resolution[1] + + for video_stream in video_url_m3u.playlists: + if best_stream is None: + best_stream = video_stream + if video_stream.stream_info.resolution: + best_resolution = video_stream.stream_info.resolution[1] + elif not video_stream.stream_info.resolution: + # already set earlier + continue + elif ((video_stream.stream_info.resolution[0] > best_stream.stream_info.resolution[0]) and + (video_stream.stream_info.resolution[1] > best_stream.stream_info.resolution[1]) and + not is_set_by_bitrate): + best_stream = video_stream + best_resolution = video_stream.stream_info.resolution[1] + elif ((video_stream.stream_info.resolution[0] == best_stream.stream_info.resolution[0]) and + (video_stream.stream_info.resolution[1] == best_stream.stream_info.resolution[1]) and + (video_stream.stream_info.bandwidth > best_stream.stream_info.bandwidth) and + not is_set_by_bitrate): + best_stream = video_stream + best_resolution = video_stream.stream_info.resolution[1] + + json_needs_updating = False + if best_stream is not None: + # use resolution over 720 as HD or + # bandwidth over 3mil + if best_resolution >= 720 and ch_json['HD'] == 0: + ch_json['HD'] = 1 + json_needs_updating = True + elif 0 < best_resolution < 720 and ch_json['HD'] == 1: + ch_json['HD'] = 0 + json_needs_updating = True + elif best_stream.stream_info.bandwidth > 3000000 and ch_json['HD'] == 0: + ch_json['HD'] = 1 + json_needs_updating = True + elif best_stream.stream_info.bandwidth <= 3000000 and ch_json['HD'] == 1: + ch_json['HD'] = 0 + json_needs_updating = True + + if best_stream.stream_info.resolution is None: + self.logger.debug( + '{} will use bandwidth at {} bps' + .format(_channel_id, str(best_stream.stream_info.bandwidth))) + else: + self.logger.notice( + self.plugin_obj.name + ': ' + _channel_id + " will use " + + str(best_stream.stream_info.resolution[0]) + "x" + + str(best_stream.stream_info.resolution[1]) + + " resolution at " + str(best_stream.stream_info.bandwidth) + "bps") + + if json_needs_updating: + self.db.update_channel_json(ch_json, self.plugin_obj.name, self.instance_key) + return best_stream.absolute_uri + else: + self.logger.debug('{}: {} No variant streams found for this station. Assuming single stream only.' + .format(self.plugin_obj.name, _channel_id)) + return _url + + def check_logger_refresh(self): + if not self.logger.isEnabledFor(40): + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) diff --git a/lib/plugins/plugin_epg.py b/lib/plugins/plugin_epg.py new file mode 100644 index 0000000000000000000000000000000000000000..e463c6bc901ffdc2e3da375619cfedba8cf00558 --- /dev/null +++ b/lib/plugins/plugin_epg.py @@ -0,0 +1,138 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import datetime +import json +import logging +import threading + +import lib.common.utils as utils +from lib.db.db_epg import DBepg +from lib.common.decorators import handle_url_except +from lib.common.decorators import handle_json_except + + +class PluginEPG: + + def __init__(self, _instance_obj): + self.logger = logging.getLogger(__name__) + self.instance_obj = _instance_obj + self.config_obj = self.instance_obj.config_obj + self.instance_key = _instance_obj.instance_key + self.plugin_obj = _instance_obj.plugin_obj + self.db = DBepg(self.config_obj.data) + self.config_section = self.instance_obj.config_section + self.episode_adj = self.config_obj.data[self.instance_obj.config_section]\ + .get('epg-episode_adjustment') + if self.episode_adj is None: + self.episode_adj = 0 + else: + self.episode_adj = int(self.episode_adj) + + def terminate(self): + """ + Removes all has a object from the object and calls any subclasses to also terminate + Not calling inherited class at this time + """ + self.logger = None + self.instance_obj = None + self.config_obj = None + self.instance_key = None + self.plugin_obj = None + self.db = None + self.config_section = None + self.episode_adj = None + + @handle_url_except(timeout=10.0) + @handle_json_except + def get_uri_data(self, _uri, _retries, _header=None): + if _header is None: + header = {'User-agent': utils.DEFAULT_USER_AGENT} + else: + header = _header + resp = self.plugin_obj.http_session.get(_uri, headers=header, timeout=8) + x = resp.json() + resp.raise_for_status() + return x + + def refresh_epg(self): + if not self.is_refresh_expired(): + self.logger.debug('EPG still new for {} {}, not refreshing'.format(self.plugin_obj.name, self.instance_key)) + return False + if not self.config_obj.data[self.instance_obj.config_section]['epg-enabled']: + self.logger.info('EPG Collection not enabled for {} {}' + .format(self.plugin_obj.name, self.instance_key)) + return False + forced_dates, aging_dates = self.dates_to_pull() + self.db.del_old_programs(self.plugin_obj.name, self.instance_key) + + for epg_day in forced_dates: + self.refresh_programs(epg_day, False) + for epg_day in aging_dates: + self.refresh_programs(epg_day, True) + self.logger.info('{}:{} EPG update completed'.format(self.plugin_obj.name, self.instance_key)) + return True + + def refresh_programs(self, _epg_day, use_cache=True): + """ + dummy method to be overridden + """ + pass + + def get_channel_days(self, _zone, _uid, _days): + """ + For a channel (uid) in a zone (like a zipcode), return + a dict listed by day with all programs listed for that day within it. + This interface is for the epg plugins + """ + pass + + def dates_to_pull(self): + """ + Returns the days to pull, if EPG is less than a day, then + override and return a simgle array value in force_days and an empty array in aging_days + """ + todaydate = datetime.date.today() + forced_days = [] + aging_days = [] + for x in range(0, self.config_obj.data[self.plugin_obj.name.lower()]['epg-days']): + if x < self.config_obj.data[self.plugin_obj.name.lower()]['epg-days_start_refresh']: + forced_days.append(todaydate + datetime.timedelta(days=x)) + else: + aging_days.append(todaydate + datetime.timedelta(days=x)) + return forced_days, aging_days + + def is_refresh_expired(self): + """ + Makes it so the minimum epg update rate + can only occur based on epg_min_refresh_rate + """ + todaydate = datetime.datetime.utcnow().date() + last_update = self.db.get_last_update(self.plugin_obj.name, self.instance_key, todaydate) + if not last_update: + return True + expired_date = datetime.datetime.now() - datetime.timedelta( + seconds=self.config_obj.data[ + self.instance_obj.config_section]['epg-min_refresh_rate']) + if last_update < expired_date: + return True + return False + + def check_logger_refresh(self): + if not self.logger.isEnabledFor(40): + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) diff --git a/lib/plugins/plugin_handler.py b/lib/plugins/plugin_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..0fe8225a0d47e4b9981f9b6a0ab1896758156a79 --- /dev/null +++ b/lib/plugins/plugin_handler.py @@ -0,0 +1,185 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import configparser +import logging +import json +import importlib +import importlib.resources +import os +import pathlib + +import lib.common.exceptions as exceptions +import lib.common.utils as utils + +from .plugin import Plugin +from .repo_handler import RepoHandler +from lib.db.db_plugins import DBPlugins +from lib.db.db_channels import DBChannels +from lib.db.db_config_defn import DBConfigDefn + +PLUGIN_DEFN_FILE = 'plugin_defn.json' + + +class PluginHandler: + logger = None + cls_plugins = None + + def __init__(self, _config_obj): + self.plugins = {} + self.config_obj = _config_obj + if PluginHandler.logger is None: + PluginHandler.logger = logging.getLogger(__name__) + self.plugin_defn = self.load_plugin_defn() + self.check_external_plugin_folder() + self.repos = RepoHandler(self.config_obj) + + self.repos.load_cabernet_repo() + self.collect_plugins(self.config_obj.data['paths']['internal_plugins_pkg'], False) + self.collect_plugins(self.config_obj.data['paths']['external_plugins_pkg'], True) + self.cleanup_config_missing_plugins() + if PluginHandler.cls_plugins is not None: + del PluginHandler.cls_plugins + PluginHandler.cls_plugins = self.plugins + + def terminate(self, _plugin_name): + """ + calls terminate to the plugin requested + """ + self.plugins[_plugin_name].terminate() + del self.plugins[_plugin_name] + + def check_external_plugin_folder(self): + """ + If the folder does not exists, then create it and place the + __init__.py file in it. + """ + ext_folder = pathlib.Path(self.config_obj.data['paths']['main_dir']) \ + .joinpath(self.config_obj.data['paths']['external_plugins_pkg']) + init_file = ext_folder.joinpath('__init__.py') + if not init_file.exists(): + self.logger.notice('Creating external plugin folder for use by Cabernet') + try: + if not ext_folder.exists(): + os.makedirs(ext_folder) + f = open(init_file, 'wb') + f.close() + except PermissionError as e: + self.logger.warning('ERROR: {} unable to create {}'.format(str(e), init_file)) + + def collect_plugins(self, _plugins_pkg, _is_external): + pkg = importlib.util.find_spec(_plugins_pkg) + if not pkg: + # module folder does not exist, do nothing + self.logger.notice( + 'plugin folder {} does not exist with a __init__.py empty file in it.' + .format(_plugins_pkg)) + return + + for folder in importlib.resources.contents(_plugins_pkg): + self.collect_plugin(_plugins_pkg, _is_external, folder) + self.del_missing_plugins() + + def cleanup_config_missing_plugins(self): + """ + Case where the plugin is deleted from folder, but database and config + still have data. + """ + ch_db = DBChannels(self.config_obj.data) + ns_inst_list = ch_db.get_channel_instances() + ns_list = ch_db.get_channel_names() + for ns in ns_list: + ns = ns['namespace'] + if not self.plugins.get(ns) and self.config_obj.data.get(ns.lower()): + for nv in self.config_obj.data.get(ns.lower()).items(): + new_value = self.set_value_type(nv[1]) + self.config_obj.data[ns.lower()][nv[0]] = new_value + for ns_inst in ns_inst_list: + if not self.plugins.get(ns_inst['namespace']): + inst_name = utils.instance_config_section(ns_inst['namespace'], ns_inst['instance']) + if self.config_obj.data.get(inst_name): + for nv in self.config_obj.data.get(inst_name).items(): + new_value = self.set_value_type(nv[1]) + self.config_obj.data[inst_name][nv[0]] = new_value + db_configdefn = DBConfigDefn(self.config_obj.data) + db_configdefn.add_config(self.config_obj.data) + + def set_value_type(self, _value): + if not isinstance(_value, str): + return _value + if _value == 'True': + return True + elif _value == 'False': + return False + elif _value.isdigit(): + return int(_value) + else: + return _value + + def collect_plugin(self, _plugins_pkg, _is_external, _folder): + if _folder.startswith('__'): + return + try: + importlib.resources.read_text(_plugins_pkg, _folder) + except (IsADirectoryError, PermissionError): + try: + plugin = Plugin(self.config_obj, self.plugin_defn, _plugins_pkg, _folder, _is_external) + self.plugins[plugin.name] = plugin + except (exceptions.CabernetException, AttributeError): + pass + except UnicodeDecodeError: + pass + except Exception: + pass + return + + def del_missing_plugins(self): + """ + updates to uninstalled the plugins from the db that are no longer present + """ + plugin_db = DBPlugins(self.config_obj.data) + plugin_dblist = plugin_db.get_plugins(_installed=True) + if plugin_dblist: + for p_dict in plugin_dblist: + if (p_dict['name'] not in self.plugins) and (p_dict['name'] != utils.CABERNET_ID): + p_dict['version']['installed'] = False + plugin_db.save_plugin(p_dict) + + def load_plugin_defn(self): + try: + defn_file = importlib.resources.read_text(self.config_obj.data['paths']['resources_pkg'], PLUGIN_DEFN_FILE) + self.logger.debug('Plugin Defn file loaded') + defn = json.loads(defn_file) + except FileNotFoundError: + self.logger.warning('PLUGIN DEFN FILE NOT FOUND AT {} {}'.format( + self.config_obj.data['paths']['resources_dir'], PLUGIN_DEFN_FILE)) + defn = {} + return defn + + def initialize_plugins(self): + for name, plugin in self.plugins.items(): + if not plugin.enabled or not self.config_obj.data[plugin.name.lower()]['enabled']: + self.logger.info('Plugin {} is disabled in config.ini'.format(plugin.name)) + plugin.enabled = False + else: + try: + plugin.plugin_obj = plugin.init_func(plugin, self.plugins) + except exceptions.CabernetException: + self.logger.debug('Setting plugin {} to disabled'.format(plugin.name)) + self.config_obj.data[plugin.name.lower()]['enabled'] = False + plugin.enabled = False diff --git a/lib/plugins/plugin_instance_obj.py b/lib/plugins/plugin_instance_obj.py new file mode 100644 index 0000000000000000000000000000000000000000..cf86f1fdf6361974fff6226831b1cd7765abfdca --- /dev/null +++ b/lib/plugins/plugin_instance_obj.py @@ -0,0 +1,185 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import threading + +import lib.common.utils as utils +from lib.db.db_scheduler import DBScheduler + + +class PluginInstanceObj: + + def __init__(self, _plugin_obj, _instance_key): + self.logger = logging.getLogger(__name__) + self.config_obj = _plugin_obj.config_obj + self.plugin_obj = _plugin_obj + self.instance_key = _instance_key + self.scheduler_db = DBScheduler(self.config_obj.data) + self.scheduler_tasks() + self.enabled = True + self.channels = None + self.programs = None + self.epg = None + if not self.config_obj.data[self.config_section]['enabled']: + self.enabled = False + else: + self.enabled = True + + def terminate(self): + """ + Removes all has a object from the object and calls any subclasses to also terminate + Not calling inherited class at this time + """ + self.enabled = False + if self.channels: + self.channels.terminate() + if self.epg: + self.epg.terminate() + if self.programs: + self.programs.terminate() + self.logger = None + self.config_obj = None + self.plugin_obj = None + self.instance_key = None + self.scheduler_db = None + self.enabled = None + self.channels = None + self.programs = None + self.epg = None + + + ############################## + # ## EXTERNAL STREAM METHODS + ############################## + + def is_time_to_refresh(self, _last_refresh): + """ + External request to determine if the m3u8 stream uri needs to + be refreshed. + Called from stream object. + """ + return False + + def get_channel_uri(self, sid): + """ + External request to return the uri for a m3u8 stream. + Called from stream object. + """ + if self.enabled and self.config_obj.data[self.config_section]['enabled']: + return self.channels.get_channel_uri(sid) + else: + self.logger.debug( + '{}:{} Plugin instance disabled, not getting Channel uri' + .format(self.plugin_obj.name, self.instance_key)) + return None + + ############################## + # ## EXTERNAL EPG METHODS + ############################## + + def get_channel_day(self, _zone, _uid, _day): + """ + External request to return the program list for a channel + based on the day requested day=0 means today + """ + if self.enabled and self.config_obj.data[self.config_section]['enabled']: + return self.epg.get_channel_day(_zone, _uid, _day) + else: + self.logger.debug( + '{}:{} Plugin instance disabled, not getting EPG channel data' + .format(self.plugin_obj.name, self.instance_key)) + return None + + def get_program_info(self, _prog_id): + """ + External request to return the program details + either from provider or from database + includes updating database if needed. + """ + if self.enabled and self.config_obj.data[self.config_section]['enabled']: + return self.programs.get_program_info(_prog_id) + else: + self.logger.debug( + '{}:{} Plugin instance disabled, not getting EPG program data' + .format(self.plugin_obj.name, self.instance_key)) + return None + + def get_channel_list(self, _zone_id, _ch_ids=None): + """ + External request to return the channel list for a zone. + if ch_ids is None, then all channels are returned + """ + if self.enabled and self.config_obj.data[self.config_section]['enabled']: + return self.channels.get_channel_list(_zone_id, _ch_ids) + else: + self.logger.debug( + '{}:{} Plugin instance disabled, not getting EPG zone data' + .format(self.plugin_obj.name, self.instance_key)) + return None + + ############################## + + def scheduler_tasks(self): + """ + dummy routine that will be overridden by subclass, + if scheduler tasks are needed at the instance level + """ + pass + + def refresh_channels(self): + """ + Called from the scheduler + """ + self.config_obj.refresh_config_data() + if self.channels is not None and \ + self.config_obj.data[self.config_section]['enabled']: + return self.channels.refresh_channels() + else: + self.logger.notice( + '{}:{} Plugin instance disabled, not refreshing Channels' + .format(self.plugin_obj.name, self.instance_key)) + return False + + def refresh_epg(self): + """ + Called from the scheduler + """ + self.config_obj.refresh_config_data() + if self.epg is not None and \ + self.config_obj.data[self.config_section]['enabled']: + return self.epg.refresh_epg() + else: + self.logger.info( + '{}:{} Plugin instance disabled, not refreshing EPG' + .format(self.plugin_obj.name, self.instance_key)) + return False + + def check_logger_refresh(self): + if not self.logger.isEnabledFor(40): + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) + if self.channels is not None: + self.channels.check_logger_refresh() + if self.epg is not None: + self.epg.check_logger_refresh() + if self.programs is not None: + self.programs.check_logger_refresh() + + @property + def config_section(self): + return utils.instance_config_section(self.plugin_obj.name, self.instance_key) diff --git a/lib/plugins/plugin_manager/__init__.py b/lib/plugins/plugin_manager/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d05eb0a50eab33d9aedad0881ecbb3031a3e71d8 --- /dev/null +++ b/lib/plugins/plugin_manager/__init__.py @@ -0,0 +1,2 @@ +import lib.plugins.plugin_manager.plugins_html +import lib.plugins.plugin_manager.plugins_form_html diff --git a/lib/plugins/plugin_manager/plugin_manager.py b/lib/plugins/plugin_manager/plugin_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..ce922eae5c7aec8f3314f2c0d17ad1859e3e87df --- /dev/null +++ b/lib/plugins/plugin_manager/plugin_manager.py @@ -0,0 +1,323 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import os +import pathlib +import plugins +import shutil +import sys +import time +import urllib +import zipfile + +import lib.common.utils as utils +from lib.db.db_plugins import DBPlugins +from lib.db.db_scheduler import DBScheduler +from lib.common.decorators import handle_url_except + + +class PluginManager: + logger = None + + def __init__(self, _plugins, _config_obj=None): + """ + If called during a patch update, the plugins is unknown, + so it should be set to None and the config object passed in instead + Otherwise, pass in the plugins and set the config object to None + """ + if PluginManager.logger is None: + PluginManager.logger = logging.getLogger(__name__) + self.plugin_handler = _plugins + if self.plugin_handler: + self.config = _plugins.config_obj.data + self.config_obj = _plugins.config_obj + else: + self.config = _config_obj.data + self.config_obj = _config_obj + + self.plugin_db = DBPlugins(self.config) + self.db_scheduler = DBScheduler(self.config) + self.plugin_rec = None + self.repo_rec = None + + def check_plugin_status(self, _repo_id, _plugin_id): + """ + Returns None if successful, otherwise, returns + string of the error + """ + self.plugin_rec = self.plugin_db.get_plugins(None, _repo_id, _plugin_id) + if not self.plugin_rec: + self.logger.notice('No plugin found, aborting') + return 'Error: No plugin found, aborting request' + + self.repo_rec = self.plugin_db.get_repos(_repo_id) + if not self.repo_rec: + self.logger.notice('No repo {} associated with plugin {}, aborting install' + .format(_repo_id, _plugin_id)) + return 'Error: No repo found {}, associated with plugin {}, aborting install' \ + .format(_repo_id, _plugin_id) + self.plugin_rec = self.plugin_rec[0] + self.repo_rec = self.repo_rec[0] + + # if plugin exists, make sure we can delete it + if self.plugin_rec['external']: + plugin_path = self.config['paths']['external_plugins_pkg'] + else: + plugin_path = self.config['paths']['internal_plugins_pkg'] + plugin_path = pathlib.Path( + self.config['paths']['main_dir'], + plugin_path, + _plugin_id + ) + if plugin_path.exists() and not os.access(plugin_path, os.W_OK): + self.logger.warning('Unable to update folder: OS Permission issue on plugin {}, aborting'.format(plugin_path)) + return 'Error: Unable to update folder: OS Permission issue on plugin {}, aborting'.format(plugin_path) + + return None + + def check_version_requirements(self): + # check Cabernet required version + req = self.plugin_rec.get('requires') + if req: + cabernet = req[0].get(utils.CABERNET_ID) + if cabernet: + ver = cabernet.get('version') + if ver: + v_req = utils.get_version_index(ver) + v_cur = utils.get_version_index(utils.VERSION) + if v_req > v_cur: + self.logger.notice('Cabernet version too low, aborting install') + return 'Error: Cabernet version {} too low for plugin. Requires {}, aborting install' \ + .format(utils.VERSION, ver) + return None + + def get_plugin_zipfile(self): + # starting install process + zip_file = ''.join([ + self.plugin_rec['id'], '-', + self.plugin_rec['version']['latest'], + '.zip' + ]) + zippath = '/'.join([ + self.repo_rec['dir']['datadir']['url'], + self.plugin_rec['id'], zip_file + ]) + tmp_zip_path = self.download_zip(zippath, 2, zip_file) + if not tmp_zip_path: + self.logger.notice('Unable to obtain zip file from repo, aborting') + results = 'Error: Unable to obtain zip file {} from repo, aborting' \ + .format(zip_file) + return (False, results) + results = 'Downloaded plugin {} from repo'.format(zip_file) + try: + with zipfile.ZipFile(tmp_zip_path, 'r') as z: + file_list = z.namelist() + res = [i for i in file_list if i.endswith(self.plugin_rec['id']+'/')] + if not res: + results += '
    Error: Zip file does not contain plugin folder {}, aborting' \ + .format(self.plugin_rec['id']) + return (False, results) + if len(res) != 1: + results += '
    Error: Zip file contains multiple plugin folders {}, aborting' \ + .format(self.plugin_rec['id']) + return (False, results) + + z.extractall(os.path.dirname(tmp_zip_path)) + + except FileNotFoundError as ex: + self.logger.notice('File {} missing from tmp area, aborting' + .format(zip_file)) + results += '
    Error: File {} missing from tmp area, aborting' \ + .format(zip_file) + return (False, results) + + tmp_plugin_path = pathlib.Path(os.path.dirname(tmp_zip_path), res[0]) + plugin_folder = pathlib.Path( + self.config['paths']['main_dir'], + self.config['paths']['external_plugins_pkg']) + + plugin_id_folder = plugin_folder.joinpath(self.plugin_rec['id']) + + if plugin_id_folder.exists(): + try: + shutil.rmtree(plugin_id_folder) + except OSError as ex: + self.logger.warning('Unable to upgrade, {}'.format(str(ex))) + results += '
    Error: Unable to delete folder for upgrade, {}'.format(str(ex)) + return (False, results) + + shutil.move(str(tmp_plugin_path), str(plugin_folder)) + results += '
    Installed plugin {} from repo, version {}' \ + .format(self.plugin_rec['id'], self.plugin_rec['version']['latest']) + + # remove the leftovers in the tmp folder + try: + p = pathlib.Path(tmp_plugin_path) + shutil.rmtree(p.parents[0]) + os.remove(tmp_zip_path) + except OSError as ex: + self.logger.notice('Unable to delete plugin from tmp area: {}'.format(str(ex))) + results += '
    Error: Unable to delete plugin folder from tmp area {}'.format(str(ex)) + return (False, results) + return (True, results) + + def upgrade_plugin(self, _repo_id, _plugin_id, _sched_queue): + results = self.check_plugin_status(_repo_id, _plugin_id) + if results: + return results + + results = self.check_version_requirements() + if results: + return results + + is_successful, results = self.get_plugin_zipfile() + if not is_successful: + return results + + # update the plugin database entry with the new version... + self.plugin_rec['version']['current'] = self.plugin_rec['version']['latest'] + self.plugin_db.save_plugin(self.plugin_rec) + + results += '
    A restart is required to finish cleaning up plugin' + return results + + def install_plugin(self, _repo_id, _plugin_id, _sched_queue=None): + results = self.check_plugin_status(_repo_id, _plugin_id) + if results: + return results + + if self.plugin_rec['version']['installed']: + self.logger.notice('Error: Plugin already installed, aborting') + return 'Error: Plugin already installed, aborting install' + + results = self.check_version_requirements() + if results: + return results + + is_successful, results = self.get_plugin_zipfile() + if not is_successful: + return results + + # next inform cabernet that there is a new plugin + if self.plugin_handler: + try: + self.plugin_handler.collect_plugin(self.config['paths']['external_plugins_pkg'], True, self.plugin_rec['id']) + except FileNotFoundError: + self.logger.notice('Plugin folder not in external plugin folder: {}'.format(str(ex))) + results += '
    Error: Plugin folder not in external plugin folder {}'.format(str(ex)) + return results + + # update the database to say plugin is installed and what version + # Enable plugin? + self.config_obj.write( + self.plugin_rec['name'].lower(), 'enabled', True) + + results += '
    A restart is suggested to finish cleaning up plugin' + return results + + def delete_plugin(self, _repo_id, _plugin_id, _sched_queue=None): + plugin_rec = self.plugin_db.get_plugins(None, _repo_id, _plugin_id) + if not plugin_rec: + self.logger.notice('No plugin found, aborting') + return 'Error: No plugin found, aborting delete request' + elif not plugin_rec[0]['version']['installed']: + self.logger.notice('Plugin not installed, aborting') + return 'Error: Plugin not installed, aborting delete request' + + plugin_rec = plugin_rec[0] + namespace = plugin_rec['name'] + if plugin_rec['external']: + plugin_path = self.config['paths']['external_plugins_pkg'] + else: + plugin_path = self.config['paths']['internal_plugins_pkg'] + + plugin_path = pathlib.Path( + self.config['paths']['main_dir'], + plugin_path, + _plugin_id + ) + if not plugin_path.exists(): + self.logger.notice('Missing plugin {}, aborting'.format(plugin_path)) + return 'Error: Missing plugin {}, aborting'.format(plugin_path) + elif not os.access(plugin_path, os.W_OK): + self.logger.warning('Unable to delete folder: OS Permission issue on plugin {}, aborting'.format(plugin_path)) + return 'Error: Unable to delete folder: OS Permission issue on plugin {}, aborting'.format(plugin_path) + + results = 'Deleting all {} scheduled tasks'.format(namespace) + tasks = self.db_scheduler.get_tasks_by_name(plugin_rec['name'], None) + if _sched_queue: + for task in tasks: + _sched_queue.put({'cmd': 'delinstance', 'name': plugin_rec['name'], 'instance': None}) + + results += '
    Deleting plugin objects' + if self.plugin_handler: + self.plugin_handler.terminate(namespace) + + results += '
    Deleting plugin folder {}'.format(plugin_path) + try: + shutil.rmtree(plugin_path) + except OSError as ex: + self.logger.notice('Unable to delete plugin: {}'.format(str(ex))) + results += '
    Error: Unable to delete plugin folder {}'.format(str(ex)) + return results + + plugin_rec['version']['installed'] = False + plugin_rec['version']['current'] = None + plugin_rec = self.plugin_db.save_plugin(plugin_rec) + + results += '
    A restart is suggested to finish cleaning up plugin' + return results + + def add_instance(self, _repo_id, _plugin_id, _sched_queue=None): + plugin_rec = self.plugin_db.get_plugins(None, _repo_id, _plugin_id) + if not plugin_rec: + self.logger.notice('No plugin found, aborting') + return 'Error: No plugin found, aborting delete request' + elif not plugin_rec[0]['version']['installed']: + self.logger.notice('Plugin not installed, aborting') + return 'Error: Plugin not installed, aborting delete request' + + plugin_rec = plugin_rec[0] + namespace = plugin_rec['name'] + + results = 'Adding Instance {}'.format(_plugin_id) + + results += '
    A restart is suggested to finish adding the instance' + return results + + + + @handle_url_except + def download_zip(self, _zip_url, _retries, _zip_filename): + """ + Returns the location of the zip file + """ + buf_size = 2 * 16 * 16 * 1024 + save_path = pathlib.Path(self.config['paths']['tmp_dir']).joinpath(_zip_filename) + + h = {'Content-Type': 'application/zip', 'User-agent': utils.DEFAULT_USER_AGENT} + req = urllib.request.Request(_zip_url, headers=h) + with urllib.request.urlopen(req) as resp: + with open(save_path, 'wb') as out_file: + while True: + chunk = resp.read(buf_size) + if not chunk: + break + out_file.write(chunk) + return save_path diff --git a/lib/plugins/plugin_manager/plugins_form_html.py b/lib/plugins/plugin_manager/plugins_form_html.py new file mode 100644 index 0000000000000000000000000000000000000000..dbb749bd7d221a8293339112d9bb494112d5497f --- /dev/null +++ b/lib/plugins/plugin_manager/plugins_form_html.py @@ -0,0 +1,467 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging + +import lib.common.utils as utils +import lib.common.exceptions as exceptions + +from lib.common.decorators import getrequest +from lib.common.decorators import postrequest +from lib.web.pages.templates import web_templates +from lib.db.db_plugins import DBPlugins +from lib.plugins.plugin_manager.plugin_manager import PluginManager + +@getrequest.route('/api/pluginsform') +def get_plugins_form_html(_webserver, _namespace=None, _sort_col=None, _sort_dir=None, filter_dict=None): + plugins_form = PluginsFormHTML(_webserver.config) + + _area = _webserver.query_data.get('area') + _plugin = _webserver.query_data.get('plugin') + _repo = _webserver.query_data.get('repo') + + if _area is None and _plugin is None and _repo is None: + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - Badly formed request')) + elif _area: + try: + form = plugins_form.get(_area) + _webserver.do_mime_response(200, 'text/html', form) + except exceptions.CabernetException as ex: + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - Badly formed area request')) + elif _plugin and _repo: + try: + form = plugins_form.get_plugin(_repo, _plugin) + _webserver.do_mime_response(200, 'text/html', form) + except exceptions.CabernetException as ex: + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - Badly formed plugin request')) + else: + # case where plugin and repo are not provided together + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - Badly formed plugin/repo request')) + + +@postrequest.route('/api/pluginsform') +def post_plugins_html(_webserver): + action = _webserver.query_data.get('action') + pluginid = _webserver.query_data.get('pluginId') + repoid = _webserver.query_data.get('repoId') + if action and pluginid and repoid: + action = action[0] + pluginid = pluginid[0] + repoid = repoid[0] + if action == "deletePlugin": + pm = PluginManager(_webserver.plugins) + results = pm.delete_plugin(repoid, pluginid, _webserver.sched_queue) + _webserver.do_mime_response(200, 'text/html', 'STATUS: Deleting plugin: {}:{}
    '.format(repoid, pluginid) + str(results)) + if action == "addInstance": + pm = PluginManager(_webserver.plugins) + results = pm.add_instance(repoid, pluginid, _webserver.sched_queue) + _webserver.do_mime_response(200, 'text/html', 'STATUS: Adding Instance plugin: {}:{}
    '.format(repoid, pluginid) + str(results)) + elif action == "installPlugin": + pm = PluginManager(_webserver.plugins) + results = pm.install_plugin(repoid, pluginid, _webserver.sched_queue) + _webserver.do_mime_response(200, 'text/html', 'STATUS: Installing plugin: {}:{}
    '.format(repoid, pluginid) + str(results)) + elif action == "upgradePlugin": + pm = PluginManager(_webserver.plugins) + results = pm.upgrade_plugin(repoid, pluginid, _webserver.sched_queue) + _webserver.do_mime_response(200, 'text/html', 'STATUS: Installing plugin: {}:{}
    '.format(repoid, pluginid) + str(results)) + else: + _webserver.do_mime_response(200, 'text/html', "doing something else"+str(action[0])) + else: + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - Badly formed request')) + + +class PluginsFormHTML: + + def __init__(self, _config): + self.logger = logging.getLogger(__name__) + self.config = _config + self.plugin_db = DBPlugins(self.config) + self.active_tab_name = None + self.num_of_plugins = 0 + self.plugin_data = None + self.area = None + + def get(self, _area): + self.area = _area + return ''.join([self.header, self.body]) + + def get_plugin(self, _repo_id, _plugin_id): + plugin_defn = self.plugin_db.get_plugins( + _installed=None, + _repo_id=_repo_id, + _plugin_id=_plugin_id) + if not plugin_defn: + self.logger.warning( + 'HTTP request: Unknown plugin: {}' + .format(_plugin_id)) + raise exceptions.CabernetException( + 'Unknown Plugin: {}' + .format(_plugin_id)) + plugin_defn = plugin_defn[0] + return ''.join([self.get_plugin_header(plugin_defn), self.get_menu_section(plugin_defn), self.get_plugin_section(plugin_defn)]) + + def get_menu_top_section(self, _plugin_defn): + return ''.join([ + '' + ]) + + def get_menu_items(self, _plugin_defn): + if not _plugin_defn['external']: + menu_list='' + elif _plugin_defn['version']['installed']: + # delete and possible upgrade... + menu_list = '' + if _plugin_defn['version']['latest'] != _plugin_defn['version']['current']: + menu_list = ''.join([ + '' + ]) + menu_list += ''.join([ + '' + ]) + else: + # install + menu_list = ''.join([ + '' + ]) + return menu_list + + def get_menu_section(self, _plugin_defn): + pluginid = _plugin_defn['id'] + repoid = _plugin_defn['repoid'] + + return ''.join([ + '', + '
    ' + '', + '', + '', + '
    ']) + + def get_plugin_header(self, _plugin_defn): + instances = self.plugin_db.get_instances(_namespace=_plugin_defn['name']) + if instances: + # array of instance names + instances = instances[_plugin_defn['name']] + else: + instances = None + + if not _plugin_defn['version'].get('latest'): + _plugin_defn['version']['latest'] = None + + html = ''.join([ + '
    ', + '
    ', + str(_plugin_defn['name']), '
    ', + + '
    ', str(_plugin_defn['summary']), '
    ', + + '
    ', + '',
+                _plugin_defn['name'],'', + '
    ', + + '
    ', + str(_plugin_defn['description']), + '
    ', + '
    ' + ]) + return html + + def get_plugin_section(self, _plugin_defn): + pluginid = _plugin_defn['id'] + repoid = _plugin_defn['repoid'] + instances = self.plugin_db.get_instances(_namespace=_plugin_defn['name']) + if instances: + # array of instance names + instances = instances[_plugin_defn['name']] + else: + instances = None + + if not _plugin_defn['version'].get('latest'): + _plugin_defn['version']['latest'] = None + + latest_version = _plugin_defn['version']['latest'] + upgrade_available = '' + if latest_version != _plugin_defn['version']['current'] and _plugin_defn['external']: + upgrade_available = ''.join([ + + '
    ' + '', + '', + '', + '' \ + .format(latest_version), + '
    ' + ]) + + if _plugin_defn['version']['installed']: + version_installed_div = ''.join([ + '
    ', + '
    Version Installed:
    ', + '
    ', + str(_plugin_defn['version']['current']), '
    ', + upgrade_available, + '
    ', + ]) + else: + version_installed_div = '' + + if _plugin_defn.get('changelog'): + changelog_div = ''.join([ + '
    ', + '
    Change Log:
    ', + '
    ', + '', + str(_plugin_defn['changelog']), '
    ', + '
    ', + ]) + else: + changelog_div = '' + + + html = ''.join([ + '', + version_installed_div, + '
    ', + '
    Latest Version:
    ', + '
    ', + str(_plugin_defn['version']['latest']), + '
    ', + '
    ', + + changelog_div, + + '
    ', + '
    Dependencies:
    ', + '
    ', + str(_plugin_defn['dependencies']), + '
    ', + '
    ', + + '
    ', + '
    Source:
    ', + '
    ', + str(_plugin_defn['source']), + '
    ', + '
    ', + + '
    ', + '
    License:
    ', + '
    ', + str(_plugin_defn['license']), + '
    ', + '
    ', + + '
    ', + '
    Author:
    ', + '
    ', + str(_plugin_defn['provider-name']), + '
    ', + '
    ', + + '
    ', + '
    Origin:
    ', + '
    ', + 'Cabernet Plugin Repository', + '
    ', + '
    ', + + '
    ', + '
    Category:
    ', + '
    ', + str(_plugin_defn['category']), + '
    ', + '
    ', + + '
    ', + '
    Related Website:
    ', + '
    ', + str(_plugin_defn['website']), + '
    ', + '
    ', + + '
    ', + '
    Instances:
    ', + '
    ', + str(instances), + '
    ', + '
    ', + + '
    ', + '
    ' + ]) + return html + + def form_plugins(self, _is_installed): + plugin_defns = self.plugin_db.get_plugins( + _is_installed, None, None) + + if not plugin_defns: + if self.area == 'My_Plugins': + return ''.join([ + 'No plugins are installed. Go to Catalog and select a plugin to install.' + ]) + elif self.area == 'Catalog': + return ''.join([ + 'All available plugins are installed' + ]) + + plugins_list = '' + for plugin_defn in sorted(plugin_defns, key=lambda p: p['id']): + repo_id = plugin_defn['repoid'] + plugin_id = plugin_defn['id'] + plugin_name = plugin_defn['name'] + + img_size = self.lookup_config_size() + + latest_version = plugin_defn['version']['latest'] + upgrade_available = '' + if _is_installed and plugin_defn['external']: + if latest_version != plugin_defn['version']['current']: + upgrade_available = '
    Upgrade to {}
    ' \ + .format(latest_version) + current_version = plugin_defn['version']['current'] + elif not _is_installed: + current_version = plugin_defn['version']['latest'] + else: + current_version = 'Internal' + + plugins_list += ''.join([ + '' + ]) + return plugins_list + + @property + def header(self): + return ''.join([ + '', + '' + ]) + + @property + def form(self): + if self.area == 'My_Plugins': + forms_html = ''.join([ + '
    ', + self.form_plugins(True), '
    ']) + elif self.area == 'Catalog': + forms_html = ''.join([ + 'Plugins Available To Install:' + '
    ', + self.form_plugins(_is_installed=False), + '
    ']) + else: + self.logger.warning('HTTP request: unknown area: {}'.format(self.area)) + raise exceptions.CabernetException('Unknown Tab: {}'.format(self.area)) + return forms_html + + @property + def body(self): + return ''.join([ + '', self.form, '']) + + def lookup_config_size(self): + size_text = self.config['channels']['thumbnail_size'] + if size_text == 'None': + return 0 + elif size_text == 'Tiny(16)': + return 16 + elif size_text == 'Small(48)': + return 48 + elif size_text == 'Medium(128)': + return 128 + elif size_text == 'Large(180)': + return 180 + elif size_text == 'X-Large(270)': + return 270 + elif size_text == 'Full-Size': + return None + else: + return None + diff --git a/lib/plugins/plugin_manager/plugins_html.py b/lib/plugins/plugin_manager/plugins_html.py new file mode 100644 index 0000000000000000000000000000000000000000..81d862efac4c4706d6ffe0cfa2cc21819c3b89d4 --- /dev/null +++ b/lib/plugins/plugin_manager/plugins_html.py @@ -0,0 +1,110 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +from lib.common.decorators import getrequest + + +@getrequest.route('/api/plugins') +def get_plugins_html(_webserver): + plugins_html = PluginsHTML() + html = plugins_html.get() + _webserver.do_mime_response(200, 'text/html', html) + + +class PluginsHTML: + + def __init__(self): + self.config = None + self.active_tab_name = None + self.tab_names = None + + def get(self): + self.tab_names = self.get_tabs() + return ''.join([self.header, self.body]) + + @property + def header(self): + return ''.join([ + '', + '', + '', + 'Plugins', + '', + '', + '', + '', + '', + '', + '' + '' + ]) + + @property + def title(self): + return ''.join([ + '
    ', + '

    Plugins

    ' + ]) + + @property + def tabs(self): + activeTab = 'activeTab' + tabs_html = ''.join([ + '
      ']) + for name, icon in self.tab_names.items(): + key = name.replace(' ', '_') + tabs_html = ''.join([ + tabs_html, + '
    • ', + '', + icon, + '', + name, '
    • ' + ]) + activeTab = '' + self.active_tab_name = name + tabs_html = ''.join([tabs_html, '
    ']) + return tabs_html + + @property + def body(self): + return ''.join([ + '', + self.title, + self.tabs, + '
    ', + '
    ', + self.plugin_page + ]) + + @property + def plugin_page(self): + return ''.join([ + '
    ' + ]) + + + + def get_tabs(self): + return {'My Plugins': 'extension', 'Catalog': 'add_shopping_cart'} + diff --git a/lib/plugins/plugin_obj.py b/lib/plugins/plugin_obj.py new file mode 100644 index 0000000000000000000000000000000000000000..24d4266bb8f1b72412b115f6edfad3749c405f93 --- /dev/null +++ b/lib/plugins/plugin_obj.py @@ -0,0 +1,310 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import base64 +import binascii +import datetime +import logging +import requests +import string +import threading +import time +import urllib.request + +import lib.common.exceptions as exceptions +from lib.db.db_scheduler import DBScheduler + + +class PluginObj: + + def __init__(self, _plugin): + self.logger = logging.getLogger(__name__) + self.plugin = _plugin + self.plugins = None + self.http_session = requests.session() + # Disable the CERT unverified warnings + requests.packages.urllib3.disable_warnings() + self.config_obj = _plugin.config_obj + self.namespace = _plugin.namespace + self.def_trans = ''.join([ + string.ascii_uppercase, + string.ascii_lowercase, + string.digits, + '+/' + ]).encode() + self.instances = {} + self.scheduler_db = DBScheduler(self.config_obj.data) + self.scheduler_tasks() + self.enabled = True + self.logger.debug('Initializing plugin {}'.format(self.namespace)) + + def terminate(self): + """ + Removes all has a object from the object and calls any subclasses to also terminate + Not calling inherited class at this time + """ + self.enabled = False + for key, instance in self.instances.items(): + return instance.terminate() + self.logger = None + self.plugin = None + self.plugins = None + self.http_session = None + self.config_obj = None + self.namespace = None + self.def_trans = None + self.instances = None + self.scheduler_db = None + + + + # INTERFACE METHODS + # Plugin may have the following methods + # used to interface to the app. + + ############################## + # ## EXTERNAL STREAM METHODS + ############################## + + def is_time_to_refresh_ext(self, _last_refresh, _instance): + """ + External request to determine if the m3u8 stream uri needs to + be refreshed. + Called from stream object. + """ + self.check_logger_refresh() + return False + + def get_channel_uri_ext(self, _sid, _instance=None): + """ + External request to return the reference uri for a m3u8 stream. + Called from stream object. + """ + self.check_logger_refresh() + return self.instances[_instance].get_channel_uri(_sid) + + def get_channel_ref_ext(self, _sid, _instance=None): + """ + External request to return the uri for a m3u8 stream. + Called from stream object. + """ + self.check_logger_refresh() + return self.instances[_instance].get_channel_ref(_sid) + + ############################## + # ## EXTERNAL EPG METHODS + ############################## + + def get_channel_day_ext(self, _zone, _uid, _day, _instance='default'): + """ + External request to return the programs for the day requested + as an offset int from current time + """ + self.check_logger_refresh() + return self.instances[_instance].get_channel_day(_zone, _uid, _day) + + def get_program_info_ext(self, _prog_id, _instance='default'): + """ + External request to return the program details + either from provider or from database + includes updating database if needed. + """ + self.check_logger_refresh() + return self.instances[_instance].get_program_info(_prog_id) + + def get_channel_list_ext(self, _zone_id, _ch_ids=None, _instance='default'): + """ + External request to return the channe list based on the zone + and the list of channels requested + """ + self.check_logger_refresh() + return self.instances[_instance].get_channel_list(_zone_id, _ch_ids) + + # END OF INTERFACE METHODS + + def scheduler_tasks(self): + """ + dummy routine that will be overridden by subclass + """ + pass + + def enable_instance(self, _namespace, _instance, _instance_name='Instance'): + """ + When one plugin is tied to another and requires it to be enabled, + this method will enable the other instance and set this plugin to disabled until + everything is up + Also used to create a new instance if missing. When _instance is None, + will look for any instance, if not will create a default one. + """ + name_config = _namespace.lower() + # if _instance is None and config has no instance for namespace, add one + if _instance is None: + x = [ k for k in self.config_obj.data.keys() if k.startswith(name_config+'_')] + if len(x): + return + else: + _instance = 'Default' + instance_config = name_config + '_' + _instance.lower() + + if self.config_obj.data.get(name_config): + if self.config_obj.data.get(instance_config): + if not self.config_obj.data[instance_config]['enabled']: + self.logger.warning('1. Enabling {}:{} plugin instance. Required by {}. Restart Required' + .format(_namespace, _instance, self.namespace)) + self.config_obj.write( + instance_config, 'enabled', True) + raise exceptions.CabernetException('{} plugin requested by {}. Restart Required' + .format(_namespace, self.namespace)) + else: + if _namespace != self.namespace: + self.logger.warning('2. Enabling {}:{} plugin instance. Required by {}. Restart Required' + .format(_namespace, _instance, self.namespace)) + else: + self.logger.warning('3. Enabling {}:{} plugin instance. Restart Required' + .format(_namespace, _instance, self.namespace)) + + self.config_obj.write( + instance_config, 'Label', _namespace + ' ' + _instance_name) + self.config_obj.write( + instance_config, 'enabled', True) + raise exceptions.CabernetException('{} plugin requested by {}. Restart Required' + .format(_namespace, self.namespace)) + else: + self.logger.error('Requested Plugin {} by {} Missing' + .format(_namespace, self.namespace)) + raise exceptions.CabernetException('Requested Plugin {} by {} Missing' + .format(_namespace, self.namespace)) + if _namespace not in self.plugins.keys(): + self.logger.warning('{}:{} not installed and requested by {} settings. Restart Required' + .format(_namespace, _instance, self.namespace)) + raise exceptions.CabernetException('{}:{} not enabled and requested by {} settings. Restart Required' + .format(_namespace, _instance, self.namespace)) + + if not self.plugins[_namespace].enabled: + self.logger.warning('{}:{} not enabled and requested by {} settings. Restart Required' + .format(_namespace, _instance, self.namespace)) + raise exceptions.CabernetException('{}:{} not enabled and requested by {} settings. Restart Required' + .format(_namespace, _instance, self.namespace)) + + def refresh_obj(self, _topic, _task_name): + if not self.enabled: + self.logger.debug( + '{} Plugin disabled, not refreshing {}' + .format(self.plugin.name, _topic)) + return + web_admin_url = 'http://localhost:' + \ + str(self.config_obj.data['web']['web_admin_port']) + task = self.scheduler_db.get_tasks(_topic, _task_name)[0] + url = (web_admin_url + '/api/scheduler?action=runtask&taskid={}' + .format(task['taskid'])) + req = urllib.request.Request(url) + with urllib.request.urlopen(req) as resp: + result = resp.read() + + # wait for the last run to update indicating the task has completed. + while True: + task_status = self.scheduler_db.get_task(task['taskid']) + x = datetime.datetime.utcnow() - task_status['lastran'] + # If updated in the last 20 minutes, then ignore + # Many media servers will request this multiple times. + if x.total_seconds() < 1200: + break + time.sleep(0.5) + + def refresh_channels(self, _instance=None): + """ + Called from the scheduler + """ + return self.refresh_it('Channels', _instance) + + def refresh_epg(self, _instance=None): + """ + Called from the scheduler + """ + return self.refresh_it('EPG', _instance) + + def refresh_it(self, _what_to_refresh, _instance=None): + """ + _what_to_refresh is either 'EPG' or 'Channels' for now + """ + try: + if not self.enabled: + self.logger.debug( + '{} Plugin disabled, not refreshing {}' + .format(self.plugin.name, _what_to_refresh)) + return False + if _instance is None: + for key, instance in self.instances.items(): + if _what_to_refresh == 'EPG': + instance.refresh_epg() + elif _what_to_refresh == 'Channels': + instance.refresh_channels() + else: + if _what_to_refresh == 'EPG': + self.instances[_instance].refresh_epg() + elif _what_to_refresh == 'Channels': + self.instances[_instance].refresh_channels() + return True + except exceptions.CabernetException: + self.logger.debug('Setting plugin {} to disabled'.format(self.plugin.name)) + self.enabled = False + self.plugin.enabled = False + return False + + def utc_to_local_time(self, _hours): + """ + Used for scheduler on events + """ + tz_delta = datetime.datetime.now() - datetime.datetime.utcnow() + tz_hours = round(tz_delta.total_seconds() / 3610) + local_hours = tz_hours + _hours + if local_hours < 0: + local_hours += 24 + elif local_hours > 23: + local_hours -= 24 + return local_hours + + def compress(self, _data): + if type(_data) is str: + _data = _data.encode() + return base64.b64encode(_data).translate( + _data.maketrans(self.def_trans, + self.config_obj.data['main']['plugin_data'].encode())) + + def uncompress(self, _data): + if type(_data) is str: + _data = _data.encode() + self.config_obj.data['main']['plugin_data'].encode() + try: + return base64.b64decode(_data.translate(_data.maketrans( + self.config_obj.data['main']['plugin_data'] + .encode(), self.def_trans))) \ + .decode() + except (binascii.Error, UnicodeDecodeError): + self.logger.error('Uncompression Error, invalid string {}'.format(_data)) + return None + + def check_logger_refresh(self): + if not self.logger.isEnabledFor(40): + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) + for inst, inst_obj in self.instances.items(): + inst_obj.check_logger_refresh() + + @property + def name(self): + return self.namespace diff --git a/lib/plugins/plugin_programs.py b/lib/plugins/plugin_programs.py new file mode 100644 index 0000000000000000000000000000000000000000..4d607e765ca28025204e35f7e4dd7bcef0e8ec0a --- /dev/null +++ b/lib/plugins/plugin_programs.py @@ -0,0 +1,70 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import json +import logging +import threading + +import lib.common.utils as utils +from lib.common.decorators import handle_url_except +from lib.common.decorators import handle_json_except + + +class PluginPrograms: + + def __init__(self, _instance_obj): + self.logger = logging.getLogger(__name__) + self.instance_obj = _instance_obj + self.config_obj = self.instance_obj.config_obj + self.instance_key = _instance_obj.instance_key + self.plugin_obj = _instance_obj.plugin_obj + self.config_section = self.instance_obj.config_section + + def get_program_info(self, _prog_id): + """ + Interface method to override + """ + pass + + def terminate(self): + """ + Removes all has a object from the object and calls any subclasses to also terminate + Not calling inherited class at this time + """ + self.logger = None + self.instance_obj = None + self.config_obj = None + self.instance_key = None + self.plugin_obj = None + self.config_section = None + + @handle_url_except() + @handle_json_except + def get_uri_data(self, _uri, _retries, _header=None): + if _header is None: + header = {'User-agent': utils.DEFAULT_USER_AGENT} + else: + header = _header + resp = self.plugin_obj.http_session.get(_uri, headers=header, timeout=8) + x = resp.json() + resp.raise_for_status() + return x + + def check_logger_refresh(self): + if not self.logger.isEnabledFor(40): + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) diff --git a/lib/plugins/repo_handler.py b/lib/plugins/repo_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..2b6932c3afd884c3e7c736320a5f86cfad9252fb --- /dev/null +++ b/lib/plugins/repo_handler.py @@ -0,0 +1,175 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import json +import importlib +import importlib.resources +import os +import pathlib +import requests +import urllib + +import lib.common.exceptions as exceptions +import lib.common.utils as utils +from lib.db.db_plugins import DBPlugins +from lib.common.decorators import handle_url_except +from lib.common.decorators import handle_json_except + + +class RepoHandler: + + http_session = requests.session() + logger = None + + def __init__(self, _config_obj): + self.config_obj = _config_obj + if RepoHandler.logger is None: + RepoHandler.logger = logging.getLogger(__name__) + self.plugin_db = DBPlugins(_config_obj.data) + + + + def load_cabernet_repo(self): + """ + Loads the manifest which points to the plugin.json list of plugins + Will update the database on the manifest and plugin list + If there is a plugin that is no longer in the list, will tag for + deletion. (don't know at this point if it is installed.) + """ + repo_settings = self.import_cabernet_manifest() + self.save_repo(repo_settings) + self.update_plugins(repo_settings) + + def import_cabernet_manifest(self): + """ + Loads the manifest for cabernet repo + """ + json_settings = importlib.resources.read_text(self.config_obj.data['paths']['resources_pkg'], utils.CABERNET_REPO) + settings = json.loads(json_settings) + if settings: + settings = settings['plugin'] + settings['repo_url'] = utils.CABERNET_REPO + self.plugin_db.get_repos(utils.CABERNET_ID) + return settings + + def save_repo(self, _repo): + """ + Saves to DB the repo json settings + """ + self.plugin_db.save_repo(_repo) + + + def cache_thumbnails(self, _plugin_defn): + """ + Determine if the cache area has the thumbnail, if not + will download and store the thumbnail + """ + # path = thumbnail cache path + plugin_id + icon or fanart path + thumbnail_path = self.config_obj.data['paths']['thumbnails_dir'] + plugin_id = _plugin_defn['id'] + icon_path = _plugin_defn['icon'] + fanart_path = _plugin_defn['fanart'] + + repoid = _plugin_defn['repoid'] + repo_defn = self.plugin_db.get_repos(repoid) + if not repo_defn: + self.logger.notice('Repo not defined for plugin {}, unable to cache thumbnails' + .format(plugin_id)) + return + datadir = repo_defn[0]['dir']['datadir']['url'] + self.cache_thumbnail(datadir, plugin_id, icon_path, thumbnail_path) + self.cache_thumbnail(datadir, plugin_id, fanart_path, thumbnail_path) + + def cache_thumbnail(self, _datadir, _plugin_id, _image_relpath, _thumbnail_path): + """ + _datadir: datadir url from the repo definition + _plugin_id: plugin id which is also the folder name + _image_repath: relative path found in the plugin definition + _thumbnail_path: config setting to the thumbnail path area + """ + full_repo = '/'.join([ + _datadir, _plugin_id, _image_relpath]) + full_cache = pathlib.Path( + _thumbnail_path, _plugin_id, _image_relpath) + if not full_cache.exists(): + image = self.get_uri_data(full_repo, 2) + self.save_file(image, full_cache) + + def update_plugins(self, _repo_settings): + """ + Gets the list of plugins for this repo from [dir][info] and updates the db + """ + uri = _repo_settings['dir']['info'] + plugin_json = self.get_uri_json_data(uri) + if plugin_json: + plugin_json = plugin_json['plugins'] + for plugin in plugin_json: + plugin = plugin['plugin'] + if 'repository' in plugin['category']: + continue + # pull the db item. merge them and then update the db with new data. + plugin_data = self.plugin_db.get_plugins(_installed=None, _repo_id=_repo_settings['id'], _plugin_id=plugin['id']) + if plugin_data: + plugin_data = plugin_data[0] + plugin['repoid'] = _repo_settings['id'] + plugin['version']['installed'] = plugin_data['version']['installed'] + plugin['version']['latest'] = plugin['version']['current'] + plugin['version']['current'] = plugin_data['version']['current'] + plugin['changelog'] = plugin.get('changelog') + if plugin_data.get('external'): + plugin['external'] = plugin_data['external'] + else: + plugin['external'] = True + else: + plugin['repoid'] = _repo_settings['id'] + plugin['version']['installed'] = False + plugin['version']['latest'] = plugin['version']['current'] + plugin['version']['current'] = None + plugin['external'] = True + self.cache_thumbnails(plugin) + self.plugin_db.save_plugin(plugin) + + @handle_url_except() + def get_uri_data(self, _uri, _retries): + header = { + 'User-agent': utils.DEFAULT_USER_AGENT} + resp = RepoHandler.http_session.get(_uri, headers=header, timeout=(2, 8)) + x = resp.content + resp.raise_for_status() + return x + + @handle_url_except() + @handle_json_except + def get_uri_json_data(self, _uri): + header = { + 'Content-Type': 'application/json', + 'User-agent': utils.DEFAULT_USER_AGENT} + req = urllib.request.Request(_uri, headers=header) + with urllib.request.urlopen(req, timeout=10.0) as resp: + return json.load(resp) + + + def save_file(self, _data, _file): + try: + os.makedirs(os.path.dirname(_file), exist_ok=True) + + open(os.path.join(_file), 'wb').write(_data) + except Exception as e: + self.logger.warning("An error occurred saving %s file\n%s" % (_file, e)) + raise diff --git a/lib/resources/__init__.py b/lib/resources/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/resources/config_defn/1-base.json b/lib/resources/config_defn/1-base.json new file mode 100644 index 0000000000000000000000000000000000000000..ba042e729a3c67db19f8802f7babcce013f6f6af --- /dev/null +++ b/lib/resources/config_defn/1-base.json @@ -0,0 +1,170 @@ +{ + "general":{ + "id": null, + "icon": "sensor_window", + "label": "Internal", + "description": "Settings that change the internals of the server", + "sections":{ + "display":{ + "label": "Display", + "sort": "1", + "icon": "tv", + "description": "Contains the general settings for the web GUI", + "settings":{ + "display_level":{ + "label": "Display Level", + "type": "list", + "default": "1-Standard", + "values": ["0-Basic", "1-Standard", + "2-Expert", "3-Advanced"], + "level": 0, + "onDefnLoad": "lib.config.config_callbacks.set_theme_folders", + "help": "Default: 1-Standard. Displays settings based on complexity" + }, + "theme":{ + "label": "Theme", + "type": "list", + "default": "spring", + "values": ["appletv", "black", "blueradiance", + "dark", "dark-red", "halloween", "holiday", + "light", "light-blue", "light-pink", + "light-purple", "light-red", "spring", "wmc"], + "level": 0, + "help": "Default: spring. Changes the way the page appears" + }, + "backgrounds":{ + "label": "Background Folder", + "type": "path", + "default": null, + "level": 0, + "help": "Default: None. Uses the images in the folder for the background" + } + } + }, + "main":{ + "label": "Main", + "sort": "2", + "icon": "sensor_window", + "description": "Contains the general settings for the app", + "settings":{ + "version":{ + "label": "Software Version", + "type": "string", + "default": null, + "level": 1, + "writable": false, + "onInit": "lib.config.config_callbacks.set_version", + "help": "Current version installed" + }, + "upgrade_quality":{ + "label": "Upgrade Quality", + "type": "list", + "default": "stable", + "values": ["stable", "unstable"], + "level": 1, + "help": "Default: stable. Version upgrade quality" + }, + "maintenance_mode":{ + "label": "Maintenance Mode", + "type": "boolean", + "default": false, + "level": 1, + "help": "Default: false. Used during upgrades. When enabled, causes the patch upgrades to be re-applied on a scheduler restart" + }, + "memory_usage":{ + "label": "Memory Usage", + "type": "boolean", + "default": false, + "level": 2, + "help": "Default: false. Turn on and set logging to DEBUG. This will generate a memory profile after each web request or scheduler trigger." + }, + "ostype":{ + "label": "OS Type", + "type": "string", + "default": null, + "level": 2, + "writable": false, + "onInit": "lib.config.config_callbacks.set_system", + "help": "Operating System running the service" + }, + "os":{ + "label": "OS Version", + "type": "string", + "default": null, + "level": 2, + "writable": false, + "onInit": "lib.config.config_callbacks.set_os", + "help": "Operating System running the service" + }, + "user":{ + "label": "Running As", + "type": "string", + "default": null, + "level": 2, + "writable": false, + "onInit": "lib.config.config_callbacks.set_user", + "help": "User that is running the service" + }, + "python_version":{ + "label": "Python Version", + "type": "string", + "default": null, + "level": 2, + "writable": false, + "onInit": "lib.config.config_callbacks.set_python_version", + "help": "Python version running service" + }, + "uuid":{ + "label": "UUID", + "type": "string", + "default": null, + "level": 3, + "writable": false, + "onInit": "lib.config.config_callbacks.set_uuid", + "help": null + }, + "encrypt_key":{ + "label": "encrypt_key", + "type": "string", + "default": null, + "level": 4, + "help": null + }, + "use_encryption":{ + "label": "use_encryption", + "type": "boolean", + "default": false, + "level": 1, + "onChange": "lib.config.config_callbacks.check_encryption", + "help": "Default: False. Will encrypt the password using a self-generated key. Use with only one user running service." + }, + "plugin_data":{ + "label": "plugin data", + "type": "string", + "default": null, + "level": 4, + "onInit": "lib.config.config_callbacks.set_pdata", + "help": null + } + } + }, + "channels":{ + "label": "Channels", + "sort": "3", + "icon": "view_list", + "description": "Channels GUI properties", + "settings":{ + "thumbnail_size":{ + "label": "Thumbnail Size", + "type": "list", + "default": "Medium(128)", + "values": ["None", "Tiny(16)", "Small(48)", "Medium(128)", + "Large(180)", "X-Large(270)", "Full-Size"], + "level": 1, + "help": "Default: Medium(128). The default size used throughout Cabernet (Channel Editor, Plugins, Config Plugin Icons)" + } + } + } + } + } +} diff --git a/lib/resources/config_defn/2-paths.json b/lib/resources/config_defn/2-paths.json new file mode 100644 index 0000000000000000000000000000000000000000..dd919d065f864544a031b10004e9d9a90d25c014 --- /dev/null +++ b/lib/resources/config_defn/2-paths.json @@ -0,0 +1,236 @@ +{ + "general":{ + "sections":{ + "paths":{ + "label": "Paths", + "sort": "3", + "icon": "perm_media", + "description": "Contains the general settings for the web GUI", + "settings":{ + "main_dir":{ + "label": "Root Path", + "type": "path", + "default": null, + "level": 1, + "writable": false, + "onInit": "lib.config.config_callbacks.set_main_path", + "help": "Not changeable. Where App is installed" + }, + "data_dir":{ + "label": "Data Path", + "type": "path", + "default": null, + "level": 2, + "onInit": "lib.config.config_callbacks.set_data_path", + "help": "Cache and Data storage" + }, + "config_file":{ + "label": "config.ini file", + "type": "path", + "default": null, + "level": 1, + "writable": false, + "help": "Not changeable. Use --config_file option to change" + }, + "db_dir":{ + "label": "Database Path", + "type": "path", + "default": null, + "level": 3, + "writable": false, + "onInit": "lib.config.config_callbacks.set_database_path", + "help": "Location of temporary files" + }, + "logs_dir":{ + "label": "Log Path", + "type": "path", + "default": null, + "level": 1, + "writable": false, + "onInit": "lib.config.config_callbacks.set_logs_path", + "help": "Location of log files when set to be used" + }, + "thumbnails_dir":{ + "label": "Thumbnails Cache Path", + "type": "path", + "default": null, + "level": 1, + "writable": false, + "onInit": "lib.config.config_callbacks.set_thumbnails_path", + "help": "Location of where cached thumbnails are stored" + }, + "tmp_dir":{ + "label": "TEMP Path", + "type": "path", + "default": null, + "level": 1, + "onInit": "lib.config.config_callbacks.set_temp_path", + "help": "Temporary Location for files and upgrades" + }, + "resources_pkg":{ + "label": "Internal Resources Path", + "type": "path", + "default": "lib.resources", + "level": 2, + "writable": false, + "help": "Not changeable, Location of the resource folder" + }, + "config_defn_pkg":{ + "label": "Internal Config Definition Path", + "type": "path", + "default": null, + "level": 3, + "writable": false, + "onInit": "lib.config.config_callbacks.set_configdefn_path", + "help": "Not changeable, Location of the base config definitions" + }, + "www_pkg":{ + "label": "WWW Path", + "type": "path", + "default": "lib.web.htdocs", + "level": 3, + "writable": false, + "help": "Where HTML, JS, Image and CSS files are located" + }, + "themes_pkg":{ + "label": "Themes Path", + "type": "path", + "default": "lib.web.htdocs.modules.themes", + "level": 3, + "writable": false, + "help": "Where the web site themes are located." + }, + "internal_plugins_pkg":{ + "label": "Main Plugins Package", + "type": "path", + "default": "plugins", + "level": 1, + "writable": false, + "help": "Package location of where the plugins are installed" + }, + "external_plugins_pkg":{ + "label": "External Plugins Package", + "type": "path", + "default": "plugins_ext", + "level": 1, + "writable": false, + "help": "Package location of where the external plugins are installed" + }, + "ffmpeg_path":{ + "label": "ffmpeg_path", + "type": "path", + "default": null, + "level": 2, + "onInit": "lib.config.config_callbacks.set_ffmpeg_path", + "help": "Used with stream_type=ffmpegproxy or when PTS Filtering or PTS/DTS Resync are enabled" + }, + "ffprobe_path":{ + "label": "ffprobe_path", + "type": "path", + "default": null, + "level": 2, + "onInit": "lib.config.config_callbacks.set_ffprobe_path", + "help": "Used when PTS Filter is enabled" + }, + "streamlink_path":{ + "label": "streamlink_path", + "type": "path", + "default": null, + "level": 2, + "onInit": "lib.config.config_callbacks.set_streamlink_path", + "help": "Used with stream_type=streamlinkproxy" + } + + } + }, + "datamgmt":{ + "label": "Data Management", + "sort": "4", + "icon": "inventory_2", + "description": "Backup and Database Configuration", + "settings":{ + "backups-backupstoretain":{ + "label": "Backups to Retain", + "type": "integer", + "default": 10, + "level": 1, + "help": "Number of backups to retain" + }, + "backups-location":{ + "label": "Path to backup location", + "type": "path", + "default": null, + "level": 2, + "onInit": "lib.config.config_callbacks.set_backup_path", + "help": "Location where backups are stored" + }, + "backups-config_ini":{ + "label": "Config.ini Backup", + "type": "path", + "default": "config.ini", + "level": 3, + "writable": false, + "help": "Used to backup the config.ini file" + }, + "db_files-defn_db":{ + "label": "Config Database", + "type": "path", + "default": "config_defn", + "level": 3, + "writable": false, + "help": "Filename of database containing config defn and config data" + }, + "db_files-plugins_db":{ + "label": "Plugin Manifest Database", + "type": "path", + "default": "plugins", + "level": 3, + "writable": false, + "help": "Filename of database containing plugin manifests" + }, + "db_files-channels_db":{ + "label": "Channels Database", + "type": "path", + "default": "channels", + "level": 3, + "writable": false, + "help": "Filename of database containing channel data" + }, + "db_files-epg_db":{ + "label": "EPG Database", + "type": "path", + "default": "epg", + "level": 3, + "writable": false, + "help": "Filename of database containing each days worth of program data" + }, + "db_files-epg_programs_db":{ + "label": "EPG Programs Database", + "type": "path", + "default": "epg_programs", + "level": 3, + "writable": false, + "help": "Filename of database containing program specific data" + }, + "db_files-scheduler_db":{ + "label": "Scheduler Database", + "type": "path", + "default": "scheduler", + "level": 3, + "writable": false, + "help": "Filename of database containing scheduled tasking" + }, + "db_files-temp_db":{ + "label": "Temporary Database", + "type": "path", + "default": "temp", + "level": 3, + "writable": false, + "help": "Filename of database containing temporary data storage" + } + + } + } + } + } +} diff --git a/lib/resources/config_defn/3-logs.json b/lib/resources/config_defn/3-logs.json new file mode 100644 index 0000000000000000000000000000000000000000..d15619f5d029230a5b32ce18ee9a2a4dd396c585 --- /dev/null +++ b/lib/resources/config_defn/3-logs.json @@ -0,0 +1,219 @@ +{ + "logging":{ + "id": null, + "icon": "article", + "label": "Logging", + "description": "Python Logging Settings", + "sections":{ + "handler_loghandler":{ + "label": "System Log Handler", + "sort": "1", + "icon": "comment", + "description": "Python system log settings", + "settings":{ + "enabled":{ + "label": "Enabled", + "type": "boolean", + "default": true, + "level": 1, + "onChange": "lib.config.config_callbacks.logging_enable", + "help": "Default: Enabled. Used to enable logging to the system logger" + }, + "level":{ + "label": "Level", + "type": "list", + "values": ["TRACE", "DEBUG", "INFO", "NOTICE", "WARNING", "ERROR", "CRITICAL"], + "default": "WARNING", + "level": 1, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": "Default: WARNING. Log level for system logs. Default is WARNING" + }, + "class":{ + "label": "class", + "type": "string", + "default": "StreamHandler", + "level": 4, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": null + }, + "formatter":{ + "label": "formatter", + "type": "list", + "values": ["extend", "simple"], + "default": "extend", + "level": 2, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": "Use with or without date stamps" + }, + "args":{ + "label": "args", + "type": "string", + "default": "(sys.stdout,)", + "level": 4, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": null + } + } + }, + "handler_filehandler":{ + "label": "File Log Handler", + "sort": "2", + "icon": "comment", + "description": "Python local log file at data/logs/ log settings", + "settings":{ + "enabled":{ + "label": "Enabled", + "type": "boolean", + "default": false, + "level": 1, + "onChange": "lib.config.config_callbacks.logging_enable", + "help": "Used to enable debug logging to the data area" + }, + "level":{ + "label": "Level", + "type": "list", + "values": ["TRACE", "DEBUG", "INFO", "NOTICE", "WARNING", "ERROR", "CRITICAL"], + "default": "INFO", + "level": 1, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": "Used for debugging. Default is INFO" + }, + "formatter":{ + "label": "formatter", + "type": "list", + "values": ["extend", "simple"], + "default": "extend", + "level": 2, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": "Use with or without date stamps" + }, + "class":{ + "label": "class", + "type": "string", + "default": "lib.common.log_handlers.MPRotatingFileHandler", + "level": 4, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": null + }, + "args":{ + "label": "args", + "type": "string", + "default": "(os.getenv('LOGS_DIR','data/logs')+'/cabernet.log', 'a', 10000000, 10)", + "level": 4, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": "Arguments are path-filename, a=append, 10000000 is max size per file, 10 is max number of files to keep" + } + } + }, + "formatter_extend":{ + "label": "formatter_extend", + "sort": "3", + "icon": "comment", + "description": "Python logging setting for standard output", + "settings":{ + "format":{ + "label": "format", + "type": "string", + "default": "%(asctime)s-%(levelname)s:%(module)s %(message)s", + "level": 3, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": "Format for extend logging" + } + } + }, + "formatter_simple":{ + "label": "formatter_simple", + "sort": "4", + "icon": "comment", + "description": "Python logging settings for outputs to event log or syslog", + "settings":{ + "format":{ + "label": "format", + "type": "string", + "default": "%(levelname)s:%(module)s %(message)s", + "level": 3, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": "Format for simple logging" + } + } + }, + "loggers":{ + "label": "loggers", + "sort": "6", + "icon": "comment", + "description": "Python logging settings", + "settings":{ + "keys":{ + "label": "keys", + "type": "string", + "default": "root", + "level": 4, + "writable": false, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": null + } + } + }, + "logger_root":{ + "label": "logger_root", + "sort": "6", + "icon": "description", + "description": "Python logging settings", + "settings":{ + "level":{ + "label": "level", + "type": "list", + "values": ["TRACE", "DEBUG", "INFO", "NOTICE", "WARNING", "ERROR", "CRITICAL"], + "default": "TRACE", + "level": 4, + "writable": false, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": "Default is TRACE" + }, + "handlers":{ + "label": "handlers", + "type": "string", + "default": "loghandler", + "level": 4, + "writable": false, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": null + } + } + }, + "handlers":{ + "label": "handlers", + "sort": "6", + "icon": "comment", + "description": "Python logging settings", + "settings":{ + "keys":{ + "label": "keys", + "type": "string", + "default": "loghandler, filehandler", + "level": 4, + "writable": false, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": null + } + } + }, + "formatters":{ + "label": "formatters", + "sort": "6", + "icon": "comment", + "description": "Python logging formats", + "settings":{ + "keys":{ + "label": "keys", + "type": "string", + "default": "extend,simple", + "level": 4, + "onChange": "lib.config.config_callbacks.logging_refresh", + "help": null + } + } + } + } + } +} \ No newline at end of file diff --git a/lib/resources/config_defn/__init__.py b/lib/resources/config_defn/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/resources/config_defn/clients.json b/lib/resources/config_defn/clients.json new file mode 100644 index 0000000000000000000000000000000000000000..04b918cc4ad10ca298c3974aae811477ffd054a3 --- /dev/null +++ b/lib/resources/config_defn/clients.json @@ -0,0 +1,250 @@ +{ + "clients":{ + "id": null, + "icon": "tv", + "label": "Clients", + "description": "Settings that support different clients", + "sections":{ + "web":{ + "label": "Web Sites", + "sort": "1", + "icon": "language", + "description": "Contains the general settings for the app", + "settings":{ + "local_ip":{ + "label": "local_ip", + "type": "string", + "default": "0.0.0.0", + "level": 1, + "onInit": "lib.config.config_callbacks.set_ip", + "help": "Default: 0.0.0.0. Use instead of plex_accessible_ip. 0.0.0.0 means bind to all IPs and use the main IP address for json data" + }, + "plex_accessible_ip":{ + "label": "plex_docker_ip", + "type": "string", + "default": "0.0.0.0", + "level": 1, + "help": "The IP provided to the clients for http requests. If using docker and video does not play, try changing this to your computer's IP" + }, + "bind_ip":{ + "label": "bind_ip", + "type": "string", + "default": "0.0.0.0", + "level": 3, + "writable": false, + "help": null + }, + "plex_accessible_port":{ + "label": "streaming_port", + "type": "integer", + "default": 5004, + "level": 1, + "help": "Default: 5004. Port used to stream. Default is 5004" + }, + "web_admin_port":{ + "label": "web_admin_port", + "type": "integer", + "default": 6077, + "level": 1, + "help": "Default: 6077. Port for main web-site. TVHeadend can use any port; however, others such as Plex and Emby need it on port 80 for full HDHR compatilibity" + }, + "disable_web_config":{ + "label": "disable_web_config", + "type": "boolean", + "default": false, + "level": 3, + "help": "Default: False. Security setting to disable the ability to edit the configuration remotely" + }, + "concurrent_listeners":{ + "label": "concurrent_listeners", + "type": "integer", + "default": 8, + "level": 3, + "help": "Default: 8. GUI Webadmin site only. Number of simultaneous HTTP requests at one time. If requests are exceeded, the request will hang until a listener becomes available." + } + } + }, + "stream":{ + "label": "Stream", + "sort": "2", + "icon": "cast", + "description": "Streaming Settings", + "settings":{ + "update_sdt":{ + "label": "ATSC SDT Update", + "type": "boolean", + "default": true, + "level": 2, + "help": "Default: True. Identifies the service name as the channel name to client. Recommend disabling unless running a Scan in the client to reduce overhead." + }, + "vod_retries":{ + "label": "VOD Retries", + "type": "integer", + "default": 2, + "level": 3, + "help": "Default: 2. Recommend leaving this as is. On VOD (with all streaming packets provided at one time), may improve timeout errors by changing the number of retries. Minimum Total Timeout = num * 12sec/timeout * 2 retries. Only applies to internalproxy." + }, + "switch_channel_timeout":{ + "label": "Switch Channel Timeout", + "type": "integer", + "default": 2, + "level": 3, + "help": "Default: 2 seconds. Clients tend to timeout streams and request a reset. This value is the time in seconds it takes to request the stop followed by re-subscribing the channel. If it is too short, Cabernet will drop the current tuner and use a new one instead of reusing the current tuner." + } + } + }, + "epg":{ + "label": "EPG", + "sort": "3", + "icon": "tune", + "description": "TV Guide or EPG settings", + "settings":{ + "description":{ + "label": "description", + "type": "list", + "default": "extend", + "values": ["normal", "brief", "extend"], + "level": 1, + "help": null + }, + "genre":{ + "label": "genre", + "type": "list", + "default": "tvheadend", + "values": ["normal", "tvheadend"], + "level": 1, + "help": "Default: tvheadend. TVHeadend uses specific genre to get colors on tv guide" + }, + "epg_channel_number":{ + "label": "Channel # in Name", + "type": "boolean", + "default": false, + "level": 2, + "help": "Default: False. When true will include the channel number in the channel name for the channel list and EPG" + }, + "epg_use_channel_number":{ + "label": "Use Channel # for Channel ID", + "type": "boolean", + "default": false, + "level": 2, + "help": "Default: False. For clients like Plex and JellyFin, they use the channel id field in the xmltv.xml as the channel number" + }, + "epg_add_plugin_to_channel_id":{ + "label": "Add Plugin name to Channel ID", + "type": "boolean", + "default": false, + "level": 2, + "help": "Default: False. For cases where the different provider have the same UID for channels" + }, + "epg_channel_icon":{ + "label": "EPG Channel Icon", + "type": "boolean", + "default": true, + "level": 2, + "help": "Default: True. When true will include the icon for each channel inside the xmltv.xml file" + }, + "epg_program_icon":{ + "label": "EPG Program Icon", + "type": "boolean", + "default": true, + "level": 2, + "help": "Default: True. When true will include the icon for each program inside the xmltv.xml file" + }, + "epg_prettyprint":{ + "label": "EPG Pretty Print", + "type": "boolean", + "default": false, + "level": 1, + "help": "Default: False. If you are having memory issues, try turning this to false" + } + } + }, + "ssdp":{ + "label": "SSDP", + "sort": "4", + "icon": "vignette", + "description": "SSDP protocol on port 1900 (partially implemented). Not required for most manual setups with media servers.", + "settings":{ + "disable_ssdp":{ + "label": "disable_ssdp", + "type": "boolean", + "default": true, + "level": 2, + "help": "Default: True. Enables SSDP protocol on port 1900. Recommend keeping this disabled and use manual setup" + }, + "udp_netmask":{ + "label": "udp_netmask", + "type": "string", + "default": null, + "level": 2, + "onInit": "lib.config.config_callbacks.set_netmask", + "onChange": "lib.config.config_callbacks.set_netmask", + "help": "Used to reduce traffic from UDP broadcast messages. Recommend using anetmask filter to a single IP, i.e., 192.168.1.130/32" + } + } + }, + "hdhomerun":{ + "label": "HDHomeRun", + "sort": "5", + "icon": "vignette", + "description": "HDHomeRun protocol on port 65001 (partially implemented). Not required for most manual setups with media servers.", + "settings":{ + "disable_hdhr":{ + "label": "disable_hdhr", + "type": "boolean", + "default": true, + "level": 2, + "help": "Default: True. Enables HDHR UDP discovery protocol on port 65001. Recommend keeping this disabled and use manual setup" + }, + "hdhr_id":{ + "label": "hdhr_id", + "type": "string", + "default": null, + "level": 2, + "writable": false, + "onInit": "lib.config.config_callbacks.set_hdhomerun_id", + "help": "Unique CRC-based hex code for this install" + }, + "udp_netmask":{ + "label": "udp_netmask", + "type": "string", + "default": null, + "level": 2, + "onInit": "lib.config.config_callbacks.set_netmask", + "onChange": "lib.config.config_callbacks.set_netmask", + "help": "Used to reduce traffic from UDP broadcast messages. Recommend using anetmask filter to a single IP, i.e., 192.168.1.130/32" + }, + "reporting_model":{ + "label": "reporting_model", + "type": "string", + "default": "HDHR5-4US", + "level": 4, + "help": null + }, + "reporting_friendly_name":{ + "label": "reporting_friendly_name", + "type": "string", + "default": "Rocky4546", + "level": 4, + "help": null + }, + "reporting_firmware_name":{ + "label": "reporting_firmware_name", + "type": "string", + "default": "hdhomerun5_atsc", + "level": 4, + "help": null + }, + "tuner_type":{ + "label": "tuner_type", + "type": "string", + "default": "Antenna", + "level": 4, + "writable": false, + "help": null + } + } + } + } + } +} \ No newline at end of file diff --git a/lib/resources/manifest.json b/lib/resources/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..e4e7ad57648f8fde3c8576f194dee32395ef36b0 --- /dev/null +++ b/lib/resources/manifest.json @@ -0,0 +1,44 @@ +{ + "plugin": { + "id": "cabernet", + "name": "Cabernet", + "version": { + "current": "0.1.0" + }, + "requires": [{ + "python": { + "name": "python", + "version": "3.7.0" + }, + "python-lib": { + "name": "cryptography", + "version": "2.8" + }, + "python-lib": { + "name": "streamlink", + "version": "5.3.1" + }, + "python-lib": { + "name": "requests", + "version": "2.26.0" + } + }], + "category": ["repository"], + "provider-name": "rocky4546", + "summary": "Cabernet allows control of IPTV streams", + "description": "Connect streams to your favorite media server. Cabernet is a modular-based appliance/platform that cleans, organizes and repackages IPTV streams to be compatible and digested by media clients.", + "license": "MIT License, Copyright (C) 2021 ROCKY4546", + "source": "https://github.com/cabernetwork/cabernet", + "forum": "https://tvheadend.org/boards/5/topics/43052", + "website": "https://cabernetwork.github.io/", + "dir": { + "github_repo_stable": "https://api.github.com/repos/cabernetwork/cabernet", + "github_repo_unstable": "https://api.github.com/repos/cabernetwork/cabernet", + "info": "https://raw.githubusercontent.com/cabernetwork/Cabernet-Repository/main/plugin.json", + "checksum": "https://raw.githubusercontent.com/cabernetwork/Cabernet-Repository/main/plugin.json.sha2", + "datadir": { + "url": "https://raw.githubusercontent.com/cabernetwork/Cabernet-Repository/main/repo" + } + } + } +} diff --git a/lib/resources/plugin_defn.json b/lib/resources/plugin_defn.json new file mode 100644 index 0000000000000000000000000000000000000000..fdff3b9ed054aea2f59c17e78adfc315305420ff --- /dev/null +++ b/lib/resources/plugin_defn.json @@ -0,0 +1,115 @@ +{ + "id": { + "label": "ID", + "type": "string", + "default": null, + "level": 0, + "help": "Folder name of the plugin" + }, + "name": { + "label": "Name", + "type": "string", + "default": null, + "level": 0, + "help": "Key name for the plugin" + }, + "version": { + "label": "Version", + "type": "string", + "default": null, + "level": 0, + "help": "Version of the plugin" + }, + "provider-name": { + "label": "Author", + "type": "string", + "default": null, + "level": 1, + "help": "Authors of the plugin" + }, + "summary": { + "label": "Summary", + "type": "string", + "default": null, + "level": 1, + "help": "Short Description of the plugin, what it provides" + }, + "description": { + "label": "Description", + "type": "string", + "default": null, + "level": 2, + "help": "Detailed Description of how to use the plugin" + }, + "category": { + "label": "Category", + "type": "list", + "default": null, + "level": 0, + "help": "Type of plugin for filtering the plugin list" + }, + "status": { + "label": "Status", + "type": "string", + "default": null, + "level": 0, + "help": "Plugin is either Enabled or Disabled" + }, + "license": { + "label": "License", + "type": "string", + "default": null, + "level": 1, + "help": "License requirements of the plugin" + }, + + "source": { + "label": "License", + "type": "string", + "default": null, + "level": 3, + "help": "License requirements of the plugin" + }, + "forum": { + "label": "License", + "type": "string", + "default": null, + "level": 2, + "help": "License requirements of the plugin" + }, + "website": { + "label": "License", + "type": "string", + "default": null, + "level": 2, + "help": "License requirements of the plugin" + }, + "disclaimer": { + "label": "Disclaimer", + "type": "string", + "default": null, + "level": 1, + "help": "Any restrictions on use" + }, + "icon": { + "label": "Icon", + "type": "path", + "default": null, + "level": 0, + "help": "Used in the list of plugins available" + }, + "fanart": { + "label": "Fanart", + "type": "path", + "default": null, + "level": 0, + "help": "Used with the plugin information view" + }, + "screenshots": { + "label": "Screenshots", + "type": "list", + "default": null, + "level": 0, + "help": "List of images displayed in the information view" + } +} \ No newline at end of file diff --git a/lib/resources/plugins/__init__.py b/lib/resources/plugins/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/resources/plugins/config_defn.json b/lib/resources/plugins/config_defn.json new file mode 100644 index 0000000000000000000000000000000000000000..4a06eec2a24cbf74d293fd48dcf002b89207cefe --- /dev/null +++ b/lib/resources/plugins/config_defn.json @@ -0,0 +1,26 @@ +{ + "providers":{ + "id": null, + "icon": "cast", + "label": "Providers", + "description": "Streaming Services", + "sections":{ + "common":{ + "label": "Common", + "sort": "Common1", + "icon": "cast", + "description": "Common settings for plugins", + "settings":{ + "enabled":{ + "label": "Enabled", + "type": "boolean", + "default": true, + "level": 1, + "help": "Will disable if an error occurs. Set to disable to disable all instances" + } + } + } + } + } +} + diff --git a/lib/resources/plugins/instance_defn.json b/lib/resources/plugins/instance_defn.json new file mode 100644 index 0000000000000000000000000000000000000000..ba547bdfd47ec3045283fbf3591c87e6aea84c81 --- /dev/null +++ b/lib/resources/plugins/instance_defn.json @@ -0,0 +1,33 @@ +{ + "providers":{ + "id": null, + "icon": "cast", + "label": "Providers", + "description": "Streaming Services", + "sections":{ + "common":{ + "label": "Common", + "sort": "Common2", + "icon": "cast_connected", + "description": "Common settings for plugin Instances", + "settings":{ + "enabled":{ + "label": "Enabled", + "type": "boolean", + "default": false, + "level": 0, + "help": "Will disable this instance only" + }, + "label":{ + "label": "Label", + "type": "string", + "default": null, + "level": 0, + "onChange": "lib.config.config_callbacks.update_instance_label", + "help": "Please restart app following a change." + } + } + } + } + } +} diff --git a/lib/resources/plugins/instance_defn_channel.json b/lib/resources/plugins/instance_defn_channel.json new file mode 100644 index 0000000000000000000000000000000000000000..95fedb253ad0f2b6ec6bb9060f58314ff73ba5b5 --- /dev/null +++ b/lib/resources/plugins/instance_defn_channel.json @@ -0,0 +1,118 @@ +{ + "providers":{ + "id": null, + "icon": "cast", + "label": "Providers", + "description": "Streaming Services", + "sections":{ + "common":{ + "label": "Common", + "sort": "Common2", + "icon": "cast_connected", + "description": "Common settings for plugin Instances", + "settings":{ + "channel-update_timeout":{ + "label": "Channel Update Timeout", + "type": "integer", + "default": 23, + "level": 3, + "help": "Channel list must be this old for update to occur when requested" + }, + "channel-import_groups":{ + "label": "Import Channel Groups", + "type": "boolean", + "default": false, + "level": 1, + "help": "Imports groups from M3U into the group other column" + }, + "channel-start_ch_num":{ + "label": "Starting Ch Number", + "type": "integer", + "default": 1, + "level": 1, + "help": "When channel number is not provided, will use this incrementally." + }, + "player-stream_type":{ + "label": "stream_type", + "type": "list", + "default": "internalproxy", + "values": ["m3u8redirect", "internalproxy", "streamlinkproxy", "ffmpegproxy"], + "level": 1, + "help": "M3U8 send m3u8 file directly to client. ffmpeg uses ffmpeg for m3u8 urls. streamlink uses the python module streamlink. internal uses internally coded modules." + }, + "player-play_all_segments":{ + "label": "Play All (VOD)", + "type": "boolean", + "default": false, + "level": 3, + "help": "When starting, will play the last segment. If VOD type stream, then enable will cause the entire video to be played from the start." + }, + "player-segments_to_play":{ + "label": "Non-VOD Segments to Start", + "type": "integer", + "default": 2, + "level": 2, + "help": "When starting, will play the last xxx segments. '1' means no buffering. Each increase means about 6 seconds of buffering." + }, + "player-decode_url":{ + "label": "Decode M3U8 URL", + "type": "boolean", + "default": false, + "level": 3, + "help": "Sometimes the provider M3U8 URL must be decoded before the HTTP request can be made. Most of the time, it does not need to be decoded." + }, + "player-enable_url_filter":{ + "label": "Enable URL Filtering", + "type": "boolean", + "default": false, + "level": 3, + "help": "Only works with internalproxy. Filters out streams from URL addresses based on regular expression" + }, + "player-url_filter":{ + "label": "URL Filter", + "type": "string", + "default": null, + "level": 3, + "help": "Only used with stream_type=internalproxy" + }, + "player-enable_pts_resync":{ + "label": "Enable PTS/DTS Resync", + "type": "boolean", + "default": false, + "level": 2, + "help": "Works with internalproxy and ffmpegproxy. Corrects timing issues with the video and audio streams and makes them contiguous" + }, + "player-pts_resync_type":{ + "label": "PTS/DTS Resync Type", + "type": "list", + "default": "ffmpeg", + "values": ["ffmpeg", "internal"], + "level": 2, + "help": "Uses either ffmpeg genpts or internal resequencing" + }, + "player-enable_pts_filter":{ + "label": "Enable PTS Filtering", + "type": "boolean", + "default": false, + "level": 3, + "help": "Works with internalproxy and ffmpegproxy. Filters out corrupted PTS packets. Requires ffprobe.exe" + }, + "player-pts_minimum":{ + "label": "pts_minimum", + "type": "integer", + "default": 10000000, + "level": 3, + "help": "Default 10,000,000 or 108 seconds after midnight. Filters out non-standard streams that whose PTS starts at midnight." + }, + "player-pts_max_delta":{ + "label": "pts_max_delta", + "type": "integer", + "default": 3000000, + "level": 3, + "help": "Default 3,000,000 or 32 seconds. when playing contiguous blocks of video, filters out any blocks that do not have a continuous PTS counter." + } + } + } + } + } +} diff --git a/lib/resources/plugins/instance_defn_epg.json b/lib/resources/plugins/instance_defn_epg.json new file mode 100644 index 0000000000000000000000000000000000000000..d314dd245cf12772722f6712f4e45eef6f86fb20 --- /dev/null +++ b/lib/resources/plugins/instance_defn_epg.json @@ -0,0 +1,83 @@ +{ + "providers":{ + "id": null, + "icon": "cast", + "label": "Providers", + "description": "Streaming Services", + "sections":{ + "common":{ + "label": "Common", + "sort": "Common2", + "icon": "cast_connected", + "description": "Common settings for plugin Instances", + "settings":{ + "epg-enabled":{ + "label": "XMLTV Enabled", + "type": "boolean", + "default": true, + "level": 1, + "help": "If disabled, will not ingest or populate the XMLTV output with this instance" + }, + "epg-xmltv_file":{ + "label": "XMLTV File", + "type": "string", + "default": null, + "level": 0, + "help": "Use http:// https:// or (Linux) file:/// (Windows) file:///C:/ Be careful when using spaces in the path" + }, + "epg-xmltv_file_type":{ + "label": "XMLTV File Type", + "type": "list", + "default": "autodetect", + "values": ["autodetect", "gzip", "zip", "xml"], + "level": 1, + "help": "When the extension of the file is not provided from the url, set this to define the type of file" + }, + "epg-prefix":{ + "label": "EPG Channel Prefix", + "type": "string", + "default": null, + "level": 1, + "help": "If a number will ADD to the display channel number; otherwise, will prepend to the channel number as a string" + }, + "epg-suffix":{ + "label": "EPG Channel Suffix", + "type": "string", + "default": null, + "level": 1, + "help": "Will append to the channel number as a string" + }, + "epg-episode_adjustment":{ + "label": "Adjusts Episode number", + "type": "list", + "default": 0, + "values": [1000, 2000, 3000, 4000, 5000], + "level": 1, + "help": "Used to record the same episode on two different channels at the same time. EX: Record the same channel from antenna and M3U at the same time." + }, + "epg-start_adjustment":{ + "label": "Start Time Adjustment", + "type": "integer", + "default": 0, + "level": 2, + "help": "Used to start each program x seconds after the program is suppose to start." + }, + "epg-end_adjustment":{ + "label": "End Time Adjustment", + "type": "integer", + "default": 0, + "level": 2, + "help": "Used to start each program x seconds before the program is suppose to end." + }, + "epg-min_refresh_rate":{ + "label": "EPG Min Refresh Rate", + "type": "integer", + "default": 3600, + "level": 2, + "help": "Default=3600 seconds (1 hours). When a HTTP request for epg data is received, will refresh the data if older than this value." + } + } + } + } + } +} diff --git a/lib/resources/plugins/plugin_repo.json b/lib/resources/plugins/plugin_repo.json new file mode 100644 index 0000000000000000000000000000000000000000..25b7d0696126aa8d249bf95f5cb3033d8e55115d --- /dev/null +++ b/lib/resources/plugins/plugin_repo.json @@ -0,0 +1,14 @@ +{ + "id": "repository.cabernet", + "name": "Cabernet Plugins", + "version": "0.0.1", + "provider-name": "rocky4546", + "extension": { + "info": "pointer to the addon.xml containing all plugins available", + "checksum": "pointer to the md5 file", + "datadir": "pointer to the repo area. Folder structure is .../repo/[id]/[id]-[version].zip", + "summary": "what this repo contains", + "description": "download and install plugins by rocky4546", + "category": ["repository"] + } +} diff --git a/lib/schedule/__init__.py b/lib/schedule/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a3692751d7e00a9b8e4687f0c74931b1cc263189 --- /dev/null +++ b/lib/schedule/__init__.py @@ -0,0 +1 @@ +import lib.schedule.schedule_html diff --git a/lib/schedule/schedule.py b/lib/schedule/schedule.py new file mode 100644 index 0000000000000000000000000000000000000000..d946b42be3e1f8d501cf245785790b3db6ef8d42 --- /dev/null +++ b/lib/schedule/schedule.py @@ -0,0 +1,864 @@ +""" +The MIT License (MIT) + +Copyright (c) 2013 Daniel Bader (http://dbader.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +Python job scheduling for humans. + +github.com/dbader/schedule + +An in-process scheduler for periodic jobs that uses the builder pattern +for configuration. Schedule lets you run Python functions (or any other +callable) periodically at pre-determined intervals using a simple, +human-friendly syntax. + +Inspired by Addam Wiggins' article "Rethinking Cron" [1] and the +"clockwork" Ruby module [2][3]. + +Features: + - A simple to use API for scheduling jobs. + - Very lightweight and no external dependencies. + - Excellent test coverage. + - Tested on Python 3.6, 3.7, 3.8, 3.9 + +Usage: + >>> import schedule + >>> import time + + >>> def job(message='stuff'): + >>> print("I'm working on:", message) + + >>> schedule.every(10).minutes.do(job) + >>> schedule.every(5).to(10).days.do(job) + >>> schedule.every().hour.do(job, message='things') + >>> schedule.every().day.at("10:30").do(job) + + >>> while True: + >>> schedule.run_pending() + >>> time.sleep(1) + +[1] https://adam.herokuapp.com/past/2010/4/13/rethinking_cron/ +[2] https://github.com/Rykian/clockwork +[3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/ +""" +from collections.abc import Hashable +import datetime +import functools +import logging +import random +import re +import time +from typing import Set, List, Optional, Callable, Union + +logger = None + + +class ScheduleError(Exception): + """Base schedule exception""" + + pass + + +class ScheduleValueError(ScheduleError): + """Base schedule value error""" + + pass + + +class IntervalError(ScheduleValueError): + """An improper interval was used""" + + pass + + +class CancelJob(object): + """ + Can be returned from a job to unschedule itself. + """ + + pass + + +class Scheduler(object): + """ + Objects instantiated by the :class:`Scheduler ` are + factories to create jobs, keep record of scheduled jobs and + handle their execution. + """ + + def __init__(self) -> None: + self.jobs: List[Job] = [] + + def run_pending(self) -> None: + """ + Run all jobs that are scheduled to run. + + Please note that it is *intended behavior that run_pending() + does not run missed jobs*. For example, if you've registered a job + that should run every minute and you only call run_pending() + in one hour increments then your job won't be run 60 times in + between but only once. + """ + runnable_jobs = (job for job in self.jobs if job.should_run) + for job in sorted(runnable_jobs): + self._run_job(job) + + def run_all(self, delay_seconds: int = 0) -> None: + """ + Run all jobs regardless if they are scheduled to run or not. + + A delay of `delay` seconds is added between each job. This helps + distribute system load generated by the jobs more evenly + over time. + + :param delay_seconds: A delay added between every executed job + """ + logger.debug( + "Running *all* %i jobs with %is delay in between", + len(self.jobs), + delay_seconds, + ) + for job in self.jobs[:]: + self._run_job(job) + time.sleep(delay_seconds) + + def get_jobs(self, tag: Optional[Hashable] = None) -> List["Job"]: + """ + Gets scheduled jobs marked with the given tag, or all jobs + if tag is omitted. + + :param tag: An identifier used to identify a subset of + jobs to retrieve + """ + if tag is None: + return self.jobs[:] + else: + return [job for job in self.jobs if tag in job.tags] + + def clear(self, tag: Optional[Hashable] = None) -> None: + """ + Deletes scheduled jobs marked with the given tag, or all jobs + if tag is omitted. + + :param tag: An identifier used to identify a subset of + jobs to delete + """ + if tag is None: + logger.debug("Deleting *all* jobs") + del self.jobs[:] + else: + logger.debug('Deleting all jobs tagged "%s"', tag) + self.jobs[:] = (job for job in self.jobs if tag not in job.tags) + + def cancel_job(self, job: "Job") -> None: + """ + Delete a scheduled job. + + :param job: The job to be unscheduled + """ + try: + logger.debug('Cancelling job "%s"', str(job)) + self.jobs.remove(job) + except ValueError: + logger.debug('Cancelling not-scheduled job "%s"', str(job)) + + def every(self, interval: int = 1) -> "Job": + """ + Schedule a new periodic job. + + :param interval: A quantity of a certain time unit + :return: An unconfigured :class:`Job ` + """ + job = Job(interval, self) + return job + + def _run_job(self, job: "Job") -> None: + ret = job.run() + if isinstance(ret, CancelJob) or ret is CancelJob: + self.cancel_job(job) + + @property + def next_run(self) -> Optional[datetime.datetime]: + """ + Datetime when the next job should run. + + :return: A :class:`~datetime.datetime` object + or None if no jobs scheduled + """ + if not self.jobs: + return None + return min(self.jobs).next_run + + @property + def idle_seconds(self) -> Optional[float]: + """ + :return: Number of seconds until + :meth:`next_run ` + or None if no jobs are scheduled + """ + if not self.next_run: + return None + return (self.next_run - datetime.datetime.now()).total_seconds() + + +class Job(object): + """ + A periodic job as used by :class:`Scheduler`. + + :param interval: A quantity of a certain time unit + :param scheduler: The :class:`Scheduler ` instance that + this job will register itself with once it has + been fully configured in :meth:`Job.do()`. + + Every job runs at a given fixed time interval that is defined by: + + * a :meth:`time unit ` + * a quantity of `time units` defined by `interval` + + A job is usually created and returned by :meth:`Scheduler.every` + method, which also defines its `interval`. + """ + + def __init__(self, interval: int, scheduler: Scheduler = None): + global logger + if logger is None: + logger = logging.getLogger("schedule") + self.interval: int = interval # pause interval * unit between runs + self.latest: Optional[int] = None # upper limit to the interval + self.job_func: Optional[functools.partial] = None # the job job_func to run + + # time units, e.g. 'minutes', 'hours', ... + self.unit: Optional[str] = None + + # optional time at which this job runs + self.at_time: Optional[datetime.time] = None + + # datetime of the last run + self.last_run: Optional[datetime.datetime] = None + + # datetime of the next run + self.next_run: Optional[datetime.datetime] = None + + # timedelta between runs, only valid for + self.period: Optional[datetime.timedelta] = None + + # Specific day of the week to start on + self.start_day: Optional[str] = None + + # optional time of final run + self.cancel_after: Optional[datetime.datetime] = None + + self.tags: Set[Hashable] = set() # unique set of tags for the job + self.scheduler: Optional[Scheduler] = scheduler # scheduler to register with + + def __lt__(self, other) -> bool: + """ + PeriodicJobs are sortable based on the scheduled time they + run next. + """ + return self.next_run < other.next_run + + def __str__(self) -> str: + if hasattr(self.job_func, "__name__"): + job_func_name = self.job_func.__name__ # type: ignore + else: + job_func_name = repr(self.job_func) + + return ("Job(interval={}, unit={}, do={}, args={}, kwargs={})").format( + self.interval, + self.unit, + job_func_name, + "()" if self.job_func is None else self.job_func.args, + "{}" if self.job_func is None else self.job_func.keywords, + ) + + def __repr__(self): + def format_time(t): + return t.strftime("%Y-%m-%d %H:%M:%S") if t else "[never]" + + def is_repr(j): + return not isinstance(j, Job) + + timestats = "(last run: %s, next run: %s)" % ( + format_time(self.last_run), + format_time(self.next_run), + ) + + if hasattr(self.job_func, "__name__"): + job_func_name = self.job_func.__name__ + else: + job_func_name = repr(self.job_func) + args = [repr(x) if is_repr(x) else str(x) for x in self.job_func.args] + kwargs = ["%s=%s" % (k, repr(v)) for k, v in self.job_func.keywords.items()] + call_repr = job_func_name + "(" + ", ".join(args + kwargs) + ")" + + if self.at_time is not None: + return "Every %s %s at %s do %s %s" % ( + self.interval, + self.unit[:-1] if self.interval == 1 else self.unit, + self.at_time, + call_repr, + timestats, + ) + else: + fmt = ( + "Every %(interval)s " + + ("to %(latest)s " if self.latest is not None else "") + + "%(unit)s do %(call_repr)s %(timestats)s" + ) + + return fmt % dict( + interval=self.interval, + latest=self.latest, + unit=(self.unit[:-1] if self.interval == 1 else self.unit), + call_repr=call_repr, + timestats=timestats, + ) + + @property + def second(self): + if self.interval != 1: + raise IntervalError("Use seconds instead of second") + return self.seconds + + @property + def seconds(self): + self.unit = "seconds" + return self + + @property + def minute(self): + if self.interval != 1: + raise IntervalError("Use minutes instead of minute") + return self.minutes + + @property + def minutes(self): + self.unit = "minutes" + return self + + @property + def hour(self): + if self.interval != 1: + raise IntervalError("Use hours instead of hour") + return self.hours + + @property + def hours(self): + self.unit = "hours" + return self + + @property + def day(self): + if self.interval != 1: + raise IntervalError("Use days instead of day") + return self.days + + @property + def days(self): + self.unit = "days" + return self + + @property + def week(self): + if self.interval != 1: + raise IntervalError("Use weeks instead of week") + return self.weeks + + @property + def weeks(self): + self.unit = "weeks" + return self + + @property + def monday(self): + if self.interval != 1: + raise IntervalError( + "Scheduling .monday() jobs is only allowed for weekly jobs. " + "Using .monday() on a job scheduled to run every 2 or more weeks " + "is not supported." + ) + self.start_day = "monday" + return self.weeks + + @property + def tuesday(self): + if self.interval != 1: + raise IntervalError( + "Scheduling .tuesday() jobs is only allowed for weekly jobs. " + "Using .tuesday() on a job scheduled to run every 2 or more weeks " + "is not supported." + ) + self.start_day = "tuesday" + return self.weeks + + @property + def wednesday(self): + if self.interval != 1: + raise IntervalError( + "Scheduling .wednesday() jobs is only allowed for weekly jobs. " + "Using .wednesday() on a job scheduled to run every 2 or more weeks " + "is not supported." + ) + self.start_day = "wednesday" + return self.weeks + + @property + def thursday(self): + if self.interval != 1: + raise IntervalError( + "Scheduling .thursday() jobs is only allowed for weekly jobs. " + "Using .thursday() on a job scheduled to run every 2 or more weeks " + "is not supported." + ) + self.start_day = "thursday" + return self.weeks + + @property + def friday(self): + if self.interval != 1: + raise IntervalError( + "Scheduling .friday() jobs is only allowed for weekly jobs. " + "Using .friday() on a job scheduled to run every 2 or more weeks " + "is not supported." + ) + self.start_day = "friday" + return self.weeks + + @property + def saturday(self): + if self.interval != 1: + raise IntervalError( + "Scheduling .saturday() jobs is only allowed for weekly jobs. " + "Using .saturday() on a job scheduled to run every 2 or more weeks " + "is not supported." + ) + self.start_day = "saturday" + return self.weeks + + @property + def sunday(self): + if self.interval != 1: + raise IntervalError( + "Scheduling .sunday() jobs is only allowed for weekly jobs. " + "Using .sunday() on a job scheduled to run every 2 or more weeks " + "is not supported." + ) + self.start_day = "sunday" + return self.weeks + + def tag(self, *tags: Hashable): + """ + Tags the job with one or more unique identifiers. + + Tags must be hashable. Duplicate tags are discarded. + + :param tags: A unique list of ``Hashable`` tags. + :return: The invoked job instance + """ + if not all(isinstance(tag, Hashable) for tag in tags): + raise TypeError("Tags must be hashable") + self.tags.update(tags) + return self + + def at(self, time_str): + + """ + Specify a particular time that the job should be run at. + + :param time_str: A string in one of the following formats: + + - For daily jobs -> `HH:MM:SS` or `HH:MM` + - For hourly jobs -> `MM:SS` or `:MM` + - For minute jobs -> `:SS` + + The format must make sense given how often the job is + repeating; for example, a job that repeats every minute + should not be given a string in the form `HH:MM:SS`. The + difference between `:MM` and `:SS` is inferred from the + selected time-unit (e.g. `every().hour.at(':30')` vs. + `every().minute.at(':30')`). + + :return: The invoked job instance + """ + if self.unit not in ("days", "hours", "minutes") and not self.start_day: + raise ScheduleValueError( + "Invalid unit (valid units are `days`, `hours`, and `minutes`)" + ) + if not isinstance(time_str, str): + raise TypeError("at() should be passed a string") + if self.unit == "days" or self.start_day: + if not re.match(r"^([0-2]\d:)?[0-5]\d:[0-5]\d$", time_str): + raise ScheduleValueError( + "Invalid time format for a daily job (valid format is HH:MM(:SS)?)" + ) + if self.unit == "hours": + if not re.match(r"^([0-5]\d)?:[0-5]\d$", time_str): + raise ScheduleValueError( + "Invalid time format for an hourly job (valid format is (MM)?:SS)" + ) + + if self.unit == "minutes": + if not re.match(r"^:[0-5]\d$", time_str): + raise ScheduleValueError( + "Invalid time format for a minutely job (valid format is :SS)" + ) + time_values = time_str.split(":") + hour: Union[str, int] + minute: Union[str, int] + second: Union[str, int] + if len(time_values) == 3: + hour, minute, second = time_values + elif len(time_values) == 2 and self.unit == "minutes": + hour = 0 + minute = 0 + _, second = time_values + elif len(time_values) == 2 and self.unit == "hours" and len(time_values[0]): + hour = 0 + minute, second = time_values + else: + hour, minute = time_values + second = 0 + if self.unit == "days" or self.start_day: + hour = int(hour) + if not (0 <= hour <= 23): + raise ScheduleValueError( + "Invalid number of hours ({} is not between 0 and 23)" + ) + elif self.unit == "hours": + hour = 0 + elif self.unit == "minutes": + hour = 0 + minute = 0 + minute = int(minute) + second = int(second) + self.at_time = datetime.time(hour, minute, second) + return self + + def to(self, latest: int): + """ + Schedule the job to run at an irregular (randomized) interval. + + The job's interval will randomly vary from the value given + to `every` to `latest`. The range defined is inclusive on + both ends. For example, `every(A).to(B).seconds` executes + the job function every N seconds such that A <= N <= B. + + :param latest: Maximum interval between randomized job runs + :return: The invoked job instance + """ + self.latest = latest + return self + + def until( + self, + until_time: Union[datetime.datetime, datetime.timedelta, datetime.time, str], + ): + """ + Schedule job to run until the specified moment. + + The job is canceled whenever the next run is calculated and it turns out the + next run is after the until_time. The job is also canceled right before it runs, + if the current time is after until_time. This latter case can happen when the + the job was scheduled to run before until_time, but runs after until_time. + + If until_time is a moment in the past, ScheduleValueError is thrown. + + :param until_time: A moment in the future representing the latest time a job can + be run. If only a time is supplied, the date is set to today. + The following formats are accepted: + + - datetime.datetime + - datetime.timedelta + - datetime.time + - String in one of the following formats: "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", "%Y-%m-%d", "%H:%M:%S", "%H:%M" + as defined by strptime() behaviour. If an invalid string format is passed, + ScheduleValueError is thrown. + + :return: The invoked job instance + """ + + if isinstance(until_time, datetime.datetime): + self.cancel_after = until_time + elif isinstance(until_time, datetime.timedelta): + self.cancel_after = datetime.datetime.now() + until_time + elif isinstance(until_time, datetime.time): + self.cancel_after = datetime.datetime.combine( + datetime.datetime.now(), until_time + ) + elif isinstance(until_time, str): + cancel_after = self._decode_datetimestr( + until_time, + [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%d", + "%H:%M:%S", + "%H:%M", + ], + ) + if cancel_after is None: + raise ScheduleValueError("Invalid string format for until()") + if "-" not in until_time: + # the until_time is a time-only format. Set the date to today + now = datetime.datetime.now() + cancel_after = cancel_after.replace( + year=now.year, month=now.month, day=now.day + ) + self.cancel_after = cancel_after + else: + raise TypeError( + "until() takes a string, datetime.datetime, datetime.timedelta, " + "datetime.time parameter" + ) + if self.cancel_after < datetime.datetime.now(): + raise ScheduleValueError( + "Cannot schedule a job to run until a time in the past" + ) + return self + + def do(self, job_func: Callable, *args, **kwargs): + """ + Specifies the job_func that should be called every time the + job runs. + + Any additional arguments are passed on to job_func when + the job runs. + + :param job_func: The function to be scheduled + :return: The invoked job instance + """ + self.job_func = functools.partial(job_func, *args, **kwargs) + functools.update_wrapper(self.job_func, job_func) + self._schedule_next_run() + if self.scheduler is None: + raise ScheduleError( + "Unable to a add job to schedule. " + "Job is not associated with an scheduler" + ) + self.scheduler.jobs.append(self) + return self + + @property + def should_run(self) -> bool: + """ + :return: ``True`` if the job should be run now. + """ + assert self.next_run is not None, "must run _schedule_next_run before" + return datetime.datetime.now() >= self.next_run + + def run(self): + """ + Run the job and immediately reschedule it. + If the job's deadline is reached (configured using .until()), the job is not + run and CancelJob is returned immediately. If the next scheduled run exceeds + the job's deadline, CancelJob is returned after the execution. In this latter + case CancelJob takes priority over any other returned value. + + :return: The return value returned by the `job_func`, or CancelJob if the job's + deadline is reached. + + """ + if self._is_overdue(datetime.datetime.now()): + logger.debug("Cancelling job %s", self) + return CancelJob + + logger.debug("Running job %s", self) + ret = self.job_func() + self.last_run = datetime.datetime.now() + self._schedule_next_run() + + if self._is_overdue(self.next_run): + logger.debug("Cancelling job %s", self) + return CancelJob + return ret + + def _schedule_next_run(self) -> None: + """ + Compute the instant when this job should run next. + """ + if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"): + raise ScheduleValueError( + "Invalid unit (valid units are `seconds`, `minutes`, `hours`, " + "`days`, and `weeks`)" + ) + + if self.latest is not None: + if not (self.latest >= self.interval): + raise ScheduleError("`latest` is greater than `interval`") + interval = random.randint(self.interval, self.latest) + else: + interval = self.interval + + self.period = datetime.timedelta(**{self.unit: interval}) + self.next_run = datetime.datetime.now() + self.period + if self.start_day is not None: + if self.unit != "weeks": + raise ScheduleValueError("`unit` should be 'weeks'") + weekdays = ( + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ) + if self.start_day not in weekdays: + raise ScheduleValueError( + "Invalid start day (valid start days are {})".format(weekdays) + ) + weekday = weekdays.index(self.start_day) + days_ahead = weekday - self.next_run.weekday() + if days_ahead <= 0: # Target day already happened this week + days_ahead += 7 + self.next_run += datetime.timedelta(days_ahead) - self.period + if self.at_time is not None: + if self.unit not in ("days", "hours", "minutes") and self.start_day is None: + raise ScheduleValueError("Invalid unit without specifying start day") + kwargs = {"second": self.at_time.second, "microsecond": 0} + if self.unit == "days" or self.start_day is not None: + kwargs["hour"] = self.at_time.hour + if self.unit in ["days", "hours"] or self.start_day is not None: + kwargs["minute"] = self.at_time.minute + self.next_run = self.next_run.replace(**kwargs) # type: ignore + # Make sure we run at the specified time *today* (or *this hour*) + # as well. This accounts for when a job takes so long it finished + # in the next period. + if not self.last_run or (self.next_run - self.last_run) > self.period: + now = datetime.datetime.now() + if ( + self.unit == "days" + and self.at_time > now.time() + and self.interval == 1 + ): + self.next_run = self.next_run - datetime.timedelta(days=1) + elif self.unit == "hours" and ( + self.at_time.minute > now.minute + or ( + self.at_time.minute == now.minute + and self.at_time.second > now.second + ) + ): + self.next_run = self.next_run - datetime.timedelta(hours=1) + elif self.unit == "minutes" and self.at_time.second > now.second: + self.next_run = self.next_run - datetime.timedelta(minutes=1) + if self.start_day is not None and self.at_time is not None: + # Let's see if we will still make that time we specified today + if (self.next_run - datetime.datetime.now()).days >= 7: + self.next_run -= self.period + + def _is_overdue(self, when: datetime.datetime): + return self.cancel_after is not None and when > self.cancel_after + + def _decode_datetimestr( + self, datetime_str: str, formats: List[str] + ) -> Optional[datetime.datetime]: + for f in formats: + try: + return datetime.datetime.strptime(datetime_str, f) + except ValueError: + pass + return None + + +# The following methods are shortcuts for not having to +# create a Scheduler instance: + +#: Default :class:`Scheduler ` object +default_scheduler = Scheduler() + +#: Default :class:`Jobs ` list +jobs = default_scheduler.jobs # todo: should this be a copy, e.g. jobs()? + + +def every(interval: int = 1) -> Job: + """Calls :meth:`every ` on the + :data:`default scheduler instance `. + """ + return default_scheduler.every(interval) + + +def run_pending() -> None: + """Calls :meth:`run_pending ` on the + :data:`default scheduler instance `. + """ + default_scheduler.run_pending() + + +def run_all(delay_seconds: int = 0) -> None: + """Calls :meth:`run_all ` on the + :data:`default scheduler instance `. + """ + default_scheduler.run_all(delay_seconds=delay_seconds) + + +def get_jobs(tag: Optional[Hashable] = None) -> List[Job]: + """Calls :meth:`get_jobs ` on the + :data:`default scheduler instance `. + """ + return default_scheduler.get_jobs(tag) + + +def clear(tag: Optional[Hashable] = None) -> None: + """Calls :meth:`clear ` on the + :data:`default scheduler instance `. + """ + default_scheduler.clear(tag) + + +def cancel_job(job: Job) -> None: + """Calls :meth:`cancel_job ` on the + :data:`default scheduler instance `. + """ + default_scheduler.cancel_job(job) + + +def next_run() -> Optional[datetime.datetime]: + """Calls :meth:`next_run ` on the + :data:`default scheduler instance `. + """ + return default_scheduler.next_run + + +def idle_seconds() -> Optional[float]: + """Calls :meth:`idle_seconds ` on the + :data:`default scheduler instance `. + """ + return default_scheduler.idle_seconds + + +def repeat(job, *args, **kwargs): + """ + Decorator to schedule a new periodic job. + + Any additional arguments are passed on to the decorated function + when the job runs. + + :param job: a :class:`Jobs ` + """ + + def _schedule_decorator(decorated_function): + job.do(decorated_function, *args, **kwargs) + return decorated_function + + return _schedule_decorator diff --git a/lib/schedule/schedule_html.py b/lib/schedule/schedule_html.py new file mode 100644 index 0000000000000000000000000000000000000000..1e4ae800fd7364d71d44fa9248d8497660226432 --- /dev/null +++ b/lib/schedule/schedule_html.py @@ -0,0 +1,518 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import datetime +import logging +import time + +from lib.common.decorators import getrequest +from lib.common.decorators import postrequest +from lib.db.db_scheduler import DBScheduler + + +@getrequest.route('/api/schedulehtml') +def get_schedule_html(_webserver): + schedule_html = ScheduleHTML(_webserver.config, _webserver.sched_queue) + if 'run' in _webserver.query_data: + schedule_html.run_task(_webserver.query_data['task']) + time.sleep(0.05) + html = schedule_html.get(_webserver.query_data) + elif 'deltask' in _webserver.query_data: + schedule_html.del_task(_webserver.query_data['task']) + time.sleep(0.05) + html = schedule_html.get(_webserver.query_data) + elif 'delete' in _webserver.query_data: + schedule_html.del_trigger(_webserver.query_data['trigger']) + time.sleep(0.05) + html = schedule_html.get_task(_webserver.query_data['task']) + elif 'trigger' in _webserver.query_data: + html = schedule_html.get_trigger(_webserver.query_data['task']) + elif 'task' in _webserver.query_data: + html = schedule_html.get_task(_webserver.query_data['task']) + else: + html = schedule_html.get(_webserver.query_data) + _webserver.do_mime_response(200, 'text/html', html) + + +@postrequest.route('/api/schedulehtml') +def post_schedule_html(_webserver): + schedule_html = ScheduleHTML(_webserver.config, _webserver.sched_queue) + html = schedule_html.post_add_trigger(_webserver.query_data) + _webserver.do_mime_response(200, 'text/html', html) + + +class ScheduleHTML: + + def __init__(self, _config, _queue): + self.logger = logging.getLogger(__name__) + self.config = _config + self.queue = _queue + self.query_data = None + self.scheduler_db = DBScheduler(self.config) + + def get(self, _query_data): + self.query_data = _query_data + return ''.join([self.header, self.body]) + + @property + def header(self): + return ''.join([ + '', + '', + '', + 'Scheduled Tasks', + '', + '', + '', + '' + ]) + + @property + def body(self): + return ''.join(['', self.title, self.schedule_tasks, + self.task, '']) + + @property + def title(self): + return ''.join([ + '
    ', + '

    Scheduled Tasks

    ' + ]) + + @property + def schedule_tasks(self): + tasks = self.scheduler_db.get_tasks() + current_area = None + + html = ''.join([ + '
    ', + '' + ]) + i = 0 + for task_dict in tasks: + i += 1 + if task_dict['area'] != current_area: + if i > 1: + html = ''.join([html, + '' + ]) + current_area = task_dict['area'] + if current_area in self.query_data: + checked = "checked" + else: + checked = "" + html = ''.join([ + html, + '
    ', + '
    ', + '', + '', + '
    ', + '
    ' + ]) + html = ''.join([html, + '
    ' + ]) + return html + + @property + def task(self): + return ''.join([ + '
    ' + ]) + + def get_task(self, _id): + task_dict = self.scheduler_db.get_task(_id) + if task_dict is None: + self.logger.warning('get_task: Invalid task id: {}'.format(_id)) + return '' + + html = ''.join([ + '', + '', + '', + '', + '', + '', + '', + '' + '', + '', + '', + '', + '', + '', + '', + ]) + + trigger_array = self.scheduler_db.get_triggers(_id) + for trigger_dict in trigger_array: + if trigger_dict['timetype'] == 'startup': + trigger_str = 'At startup' + elif trigger_dict['timetype'] == 'daily': + trigger_str = 'Daily at ' + trigger_dict['timeofday'] + elif trigger_dict['timetype'] == 'weekly': + trigger_str = ''.join([ + 'Every ', trigger_dict['dayofweek'], + ' at ', trigger_dict['timeofday'] + ]) + elif trigger_dict['timetype'] == 'interval': + interval_mins = trigger_dict['interval'] + remainder_hrs = interval_mins % 60 + if remainder_hrs != 0: + interval_str = str(interval_mins) + ' minutes' + else: + interval_hrs = interval_mins // 60 + interval_str = str(interval_hrs) + ' hours' + trigger_str = 'Every ' + interval_str + if trigger_dict['randdur'] != -1: + trigger_str += ' with random maximum added time of ' + str(trigger_dict['randdur']) + ' minutes' + + else: + trigger_str = 'UNKNOWN' + + html = ''.join([ + html, + '', + '', + '', + '', + '' + ]) + + return ''.join([ + html, + '
    ', + '', + '
    arrow_back
    ', + str(task_dict['title']), '
    ', str(task_dict['description']), '
    Namespace: ', str(task_dict['namespace']), + '   Instance: ', str(task_dict['instance']), + '   Priority: ', str(task_dict['priority']), + '   Thread Type: ', str(task_dict['threadtype']), + '
     
    Task Triggers', + '', + '
    ', + 'schedule', + trigger_str, + '', + '', + 'delete_forever
    ' + ]) + + def get_trigger(self, _id): + task_dict = self.scheduler_db.get_task(_id) + if task_dict is None: + self.logger.warning('get_trigger: Invalid task id: {}'.format(_id)) + return '' + if task_dict['namespace'] is None: + namespace = "" + else: + namespace = task_dict['namespace'] + if task_dict['instance'] is None: + instance = "" + else: + instance = task_dict['instance'] + + return "".join([ + '', + '
    ', + '', + '', + '', + '', + '', + '' + '', + '', + '' + '', + '', + '', + '', + '', + '', + '', + '
    ', + '', + '
    arrow_back
    ', + '
    ', + 'Add Trigger
    Task: ', task_dict['title'], + '', + '

    ', + '

    ', + '', + '
    Day:   ', + '', + '

    ', + '
    ', + ' : ', + '', '

    ', + '
    Every:   ', + '

    ', + '
    Max Random Added Time:   ', + '

    ', + '
    ', + '   ', + '
     
    ', + '
    ' + ]) + + def post_add_trigger(self, query_data): + if query_data['timetype'][0] == 'startup': + self.queue.put({'cmd': 'add', 'trigger': { + 'area': query_data['area'][0], + 'title': query_data['title'][0], + 'timetype': query_data['timetype'][0] + }}) + time.sleep(0.05) + return 'Startup Trigger added' + + elif query_data['timetype'][0] == 'daily': + if query_data['timeofdayhr'][0] is None or query_data['timeofdaymin'][0] is None: + return 'Time of Day is not set and is required' + self.queue.put({'cmd': 'add', 'trigger': { + 'area': query_data['area'][0], + 'title': query_data['title'][0], + 'timetype': query_data['timetype'][0], + 'timeofday': query_data['timeofdayhr'][0] + ':' + query_data['timeofdaymin'][0] + }}) + time.sleep(0.05) + return 'Daily Trigger added' + + elif query_data['timetype'][0] == 'weekly': + if query_data['dayofweek'][0] is None: + return 'Day of Week is not set and is required' + if query_data['timeofdayhr'][0] is None or query_data['timeofdaymin'][0] is None: + return 'Time of Day is not set and is required' + self.queue.put({'cmd': 'add', 'trigger': { + 'area': query_data['area'][0], + 'title': query_data['title'][0], + 'timetype': query_data['timetype'][0], + 'timeofday': query_data['timeofdayhr'][0] + ':' + query_data['timeofdaymin'][0], + 'dayofweek': query_data['dayofweek'][0] + }}) + time.sleep(0.05) + return 'Weekly Trigger added' + + elif query_data['timetype'][0] == 'interval': + if query_data['interval'][0] is None: + return 'Interval is not set and is required' + self.queue.put({'cmd': 'add', 'trigger': { + 'area': query_data['area'][0], + 'title': query_data['title'][0], + 'timetype': query_data['timetype'][0], + 'interval': query_data['interval'][0], + 'randdur': query_data['randdur'][0] + }}) + time.sleep(0.05) + return 'Interval Trigger added' + return 'UNKNOWN' + + def del_trigger(self, _uuid): + if self.scheduler_db.get_trigger(_uuid) is None: + return None + self.queue.put({'cmd': 'del', 'uuid': _uuid}) + time.sleep(0.05) + return 'Interval Trigger deleted' + + def run_task(self, _taskid): + self.queue.put({'cmd': 'runtask', 'taskid': _taskid}) + return None + + def del_task(self, _taskid): + self.queue.put({'cmd': 'deltask', 'taskid': _taskid}) + return None diff --git a/lib/schedule/scheduler.py b/lib/schedule/scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..b119efc82794a4fddb60b9013ec8dfe7d3e526b4 --- /dev/null +++ b/lib/schedule/scheduler.py @@ -0,0 +1,361 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import importlib +import logging +import time +from multiprocessing import Process +from threading import Thread + +import lib.schedule.schedule +import lib.common.exceptions as exceptions +from lib.common.decorators import getrequest +from lib.db.db_scheduler import DBScheduler +from lib.web.pages.templates import web_templates + + +@getrequest.route('/api/scheduler') +def get_scheduler(_webserver): + try: + if _webserver.query_data.get('action') == 'runtask': + _webserver.sched_queue.put({'cmd': 'runtask', 'taskid': _webserver.query_data.get('taskid')}) + time.sleep(0.1) + _webserver.do_mime_response(200, 'text/html', 'action executed: ' + _webserver.query_data['action']) + return + else: + _webserver.do_mime_response( + 501, 'text/html', + web_templates['htmlError'].format('501 - Unknown action')) + except KeyError: + _webserver.do_mime_response( + 501, 'text/html', + web_templates['htmlError'].format('501 - Badly formed request')) + + +class Scheduler(Thread): + """ + Assumed to be a singleton + triggers are associated with a task in the database and define when a task runs + jobs are listed in the Schedule object and run as cron jobs. Triggers with + their associated tasks define jobs. + Calls are from the sched_queue to run, delete or add triggers/jobs. + Tasks are not managed by this class. + Only one trigger/job can run from within a task at any point in time. + """ + scheduler_obj = None + + def __init__(self, _plugins, _queue): + Thread.__init__(self) + self.logger = logging.getLogger(__name__) + self.plugins = _plugins + self.queue = _queue + self.config_obj = _plugins.config_obj + self.scheduler_db = DBScheduler(self.config_obj.data) + self.scheduler_db.reset_activity() + self.schedule = lib.schedule.schedule + self.daemon = True + self.stop_thread = False + Scheduler.scheduler_obj = self + + def _queue_thread(): + while not self.stop_thread: + queue_item = self.queue.get(True) + self.process_queue(queue_item) + + _q_thread = Thread(target=_queue_thread, args=()) + _q_thread.start() + self.start() + + def run(self): + """ + Thread run method for the class. + - Executes all startup tasks + - Sets up the Schedule/Job objects based on database + - Loops getting queue events and runs any pending triggers + """ + self.setup_triggers() + triggers = self.scheduler_db.get_triggers_by_type('startup') + for trigger in triggers: + self.exec_trigger(trigger) + while not self.stop_thread: + self.schedule.run_pending() + for i in range(30): + if self.stop_thread: + break + time.sleep(1) + + def terminate(self): + self.stop_thread = True + self.queue.put({'cmd': 'noop'}) + + def exec_trigger(self, _trigger): + """ + Main entry for the Schedule Job to run a task/event + """ + if self.scheduler_db.get_active_status(_trigger['taskid']): + self.logger.debug('Task currently running, ignored request {}:{}'.format( + _trigger['area'], _trigger['title'])) + return + + self.scheduler_db.start_task(_trigger['area'], _trigger['title']) + if _trigger['threadtype'] == 'thread': + self.logger.notice('Running threaded task {}:{}'.format( + _trigger['area'], _trigger['title'])) + t_event = Thread(target=self.call_trigger, args=(_trigger,)) + t_event.start() + elif _trigger['threadtype'] == 'process': + self.logger.notice('Running process task {}:{}'.format( + _trigger['area'], _trigger['title'])) + p_event = Process(target=self.call_trigger, args=(_trigger,)) + p_event.start() + else: + self.logger.notice('Running inline task {}:{}'.format( + _trigger['area'], _trigger['title'])) + self.call_trigger(_trigger) + + def call_trigger(self, _trigger): + """ + Calls the trigger function and times the result + """ + start = time.time() + try: + if _trigger['namespace'] == 'internal': + mod_name, func_name = _trigger['funccall'].rsplit('.', 1) + mod = importlib.import_module(mod_name) + call_f = getattr(mod, func_name) + results = call_f(self.plugins) + else: + if _trigger['namespace'] not in self.plugins.plugins: + self.logger.debug( + '{} scheduled tasks ignored. plugin missing' + .format(_trigger['namespace'])) + results = False + else: + plugin_obj = self.plugins.plugins[_trigger['namespace']].plugin_obj + if plugin_obj is None: + self.logger.debug( + '{} scheduled tasks ignored. plugin disabled' + .format(_trigger['namespace'])) + results = False + elif _trigger['instance'] is None: + call_f = getattr(plugin_obj, _trigger['funccall']) + results = call_f() + elif plugin_obj.instances.get(_trigger['instance']): + call_f = getattr(plugin_obj.instances[_trigger['instance']], + _trigger['funccall']) + results = call_f() + else: + self.logger.debug( + '{}:{} scheduled tasks ignored. instance missing' + .format(_trigger['namespace'], _trigger['instance'])) + results = False + + except exceptions.CabernetException as ex: + self.logger.warning('{}'.format(str(ex))) + results = False + except Exception as ex: + self.logger.exception('{}{}'.format( + 'UNEXPECTED EXCEPTION on GET=', ex)) + results = False + if results is None: + results = True + end = time.time() + duration = int(end - start) + if results: + time.sleep(0.2) + self.scheduler_db.finish_task(_trigger['area'], _trigger['title'], duration) + else: + self.scheduler_db.reset_activity(False, _trigger['area'], _trigger['title']) + + def setup_triggers(self): + """ + Assumes the trigger is already in the database and adds the job + to the Schedule object + """ + triggers = self.scheduler_db.get_triggers_by_type('daily') + for trigger_data in triggers: + self.add_job(trigger_data) + + triggers = self.scheduler_db.get_triggers_by_type('weekly') + for trigger_data in triggers: + self.add_job(trigger_data) + + triggers = self.scheduler_db.get_triggers_by_type('interval') + for trigger_data in triggers: + self.add_job(trigger_data) + + def add_job(self, _trigger): + """ + Adds a job to the schedule object using the trigger dict from the database + """ + if _trigger['timetype'] == 'daily': + self.schedule.every().day.at(_trigger['timeofday']).do( + self.exec_trigger, _trigger) \ + .tag(_trigger['uuid']) + elif _trigger['timetype'] == 'weekly': + getattr(self.schedule.every(), _trigger['dayofweek'].lower()) \ + .at(_trigger['timeofday']).do( + self.exec_trigger, _trigger) \ + .tag(_trigger['uuid']) + elif _trigger['timetype'] == 'interval': + if _trigger['randdur'] < 0: + self.schedule.every(_trigger['interval']).minutes.do( + self.exec_trigger, _trigger) \ + .tag(_trigger['uuid']) + else: + self.schedule.every(_trigger['interval']) \ + .to(_trigger['interval'] + _trigger['randdur']) \ + .minutes.do(self.exec_trigger, _trigger) \ + .tag(_trigger['uuid']) + elif _trigger['timetype'] == 'startup': + pass + else: + self.logger.warning('Bad trigger timetype called {}'.format(_trigger)) + + # Need to add UNTIL method to trigger when provided + # database has timelimit in minutes and by default is set to -1. + # until does not work that way. Use a second trigger to clear the first if it is still running. + # but only works when the randum generator is not used. + # also it won't work for inline triggers since a second trigger cannot run. + + def process_queue(self, _queue_item): + """ + cmd: run_job, arg: uuid + cmd: del_job, arg: uuid + cmd: add_job, arg: trigger data without uuid + """ + try: + if _queue_item['cmd'] == 'run': + self.run_trigger(_queue_item['uuid']) + elif _queue_item['cmd'] == 'runtask': + self.run_task(_queue_item['taskid']) + elif _queue_item['cmd'] == 'deltask': + self.delete_task(_queue_item['taskid']) + elif _queue_item['cmd'] == 'delinstance': + self.delete_instance(_queue_item['name'], _queue_item['instance']) + elif _queue_item['cmd'] == 'del': + self.delete_trigger(_queue_item['uuid']) + elif _queue_item['cmd'] == 'add': + self.add_trigger(_queue_item['trigger']) + elif _queue_item['cmd'] == 'noop': + pass + else: + self.logger.warning('UNKNOWN Scheduler cmd from queue: {}'.format(_queue_item)) + except KeyError as e: + self.logger.warning('Badly formed scheduled request {} {}'.format(_queue_item, repr(e))) + + def delete_trigger(self, _uuid): + self.logger.debug('Deleting trigger {}'.format(_uuid)) + jobs = self.schedule.get_jobs(_uuid) + for job in jobs: + self.schedule.cancel_job(job) + self.scheduler_db.del_trigger(_uuid) + + def run_trigger(self, _uuid): + jobs = self.schedule.get_jobs(_uuid) + if len(jobs) == 0: + self.logger.info('Invalid trigger uuid job in schedule for run request {}'.format(_uuid)) + else: + for job in jobs: + job.run() + + def add_trigger(self, trigger): + if trigger['timetype'] == 'startup': + self.create_trigger(trigger['area'], trigger['title'], + trigger['timetype']) + elif trigger['timetype'] == 'daily': + self.create_trigger(trigger['area'], trigger['title'], + trigger['timetype'], + timeofday=trigger['timeofday'] + ) + elif trigger['timetype'] == 'daily': + self.create_trigger(trigger['area'], trigger['title'], + trigger['timetype'], + timeofday=trigger['timeofday'] + ) + elif trigger['timetype'] == 'weekly': + self.create_trigger(trigger['area'], trigger['title'], + trigger['timetype'], + timeofday=trigger['timeofday'], + dayofweek=trigger['dayofweek'] + ) + elif trigger['timetype'] == 'interval': + self.create_trigger(trigger['area'], trigger['title'], + trigger['timetype'], + interval=trigger['interval'], + randdur=trigger['randdur'] + ) + + def create_trigger(self, _area, _title, _timetype, timeofday=None, + dayofweek=None, interval=-1, timelimit=-1, randdur=-1): + self.logger.notice('Creating trigger {}:{}:{}'.format(_area, _title, _timetype)) + uuid = self.scheduler_db.save_trigger(_area, _title, _timetype, timeofday, + dayofweek, interval, timelimit, randdur) + trigger = self.scheduler_db.get_trigger(uuid) + self.add_job(trigger) + + def delete_instance(self, _name, _instance): + tasks = self.scheduler_db.get_tasks_by_name(_name, _instance) + for task in tasks: + self.logger.warning('deleting task {}'.format(task['taskid'])) + self.delete_task(task['taskid']) + + def delete_task(self, _taskid): + task = self.scheduler_db.get_task(_taskid) + if task is None: + self.logger.notice('Task to delete missing: {}'.format(_taskid)) + return + + triggers = self.scheduler_db.get_triggers(_taskid) + for trigger in triggers: + self.delete_trigger(trigger['uuid']) + self.logger.debug('Deleting schedule task: {}'.format(_taskid)) + self.scheduler_db.del_task(task['area'], task['title']) + + def run_task(self, _taskid): + triggers = self.scheduler_db.get_triggers(_taskid) + if len(triggers) == 0: + # check if the task has no triggers + task = self.scheduler_db.get_task(_taskid) + if task is not None: + self.exec_trigger(task) + else: + self.logger.warning('Invalid taskid when requesting to run task') + return + + is_run = False + default_trigger = None + trigger = None + for trigger in triggers: + if trigger['timetype'] == 'startup': + continue + elif trigger['timetype'] == 'interval': + self.queue.put({'cmd': 'run', 'uuid': trigger['uuid']}) + is_run = True + break + else: + default_trigger = trigger + if not is_run: + if default_trigger is not None: + self.queue.put({'cmd': 'run', 'uuid': trigger['uuid']}) + else: + task = self.scheduler_db.get_task(_taskid) + if task is not None: + self.exec_trigger(task) + else: + self.logger.warning('Invalid taskid when requesting to run task') diff --git a/lib/streams/atsc.py b/lib/streams/atsc.py new file mode 100644 index 0000000000000000000000000000000000000000..fad3dc8d40448dfabd355089dbfbfbdcb8cb856c --- /dev/null +++ b/lib/streams/atsc.py @@ -0,0 +1,815 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import binascii +import struct +import datetime +import logging +import lib.common.utils as utils +from lib.common.algorithms import Crc +from lib.common.models import CrcModels + +ATSC_EXTENDED_CHANNEL_DESCR_TAG = b'\xA0' +ATSC_SERVICE_LOCATION_DESCR_TAG = b'\xA1' +ATSC_VIRTUAL_CHANNEL_TABLE_TAG = b'\xC8' +ATSC_MASTER_GUIDE_TABLE_TAG = b'\xC7' +ATSC_SERVICE_DESCR_TABLE_TAG = b'\x42' +MPEG2_PROGRAM_SYSTEM_TIME_TABLE_TAG = b'\xCD' +MPEG2_PROGRAM_ASSOCIATION_TABLE_TAG = b'\x00' +MPEG2_CONDITIONAL_ACCESS_TABLE_TAG = b'\x01' +MPEG2_PROGRAM_MAP_TABLE_TAG = b'\x02' + +ATSC_MSG_LEN = 188 +LEAP_SECONDS_1980 = 19 +LEAP_SECONDS_2021 = 37 # this has not changed since 2017 + + +class ATSCMsg: + # class that generates most of the ATSC UDP protocol messages + + + # UDP msgs for ATSC + # https://www.atsc.org/wp-content/uploads/2015/03/Program-System-Information-Protocol-for-Terrestrial-Broadcast-and-Cable-1.pdf + + def __init__(self): + self.logger = logging.getLogger(__name__) + models = CrcModels() + crc32_mpeg_model = models.get_params('crc-32-mpeg') + self.crc_width = crc32_mpeg_model['width'] + self.crc_poly = crc32_mpeg_model['poly'] + self.crc_reflect_in = crc32_mpeg_model['reflect_in'] + self.crc_xor_in = crc32_mpeg_model['xor_in'] + self.crc_reflect_out = crc32_mpeg_model['reflect_out'] + self.crc_xor_out = crc32_mpeg_model['xor_out'] + self.crc_table_idx_width = 8 + self.atsc_blank_section = b'\x47\x1f\xff\x10\x00'.ljust(ATSC_MSG_LEN, b'\xff') + self.type_strings = [] + self.msg_counter = {} + + def gen_crc_mpeg(self, _msg): + alg = Crc( + width=self.crc_width, + poly=self.crc_poly, + reflect_in=self.crc_reflect_in, + xor_in=self.crc_xor_in, + reflect_out=self.crc_reflect_out, + xor_out=self.crc_xor_out, + table_idx_width=8, + ) + crc_int = alg.bit_by_bit(_msg) + crc = struct.pack('>I', crc_int) + return crc + + def gen_header(self, _pid): + # pid is an integer + # pid = PMT channel pid + # STT 1FFB + # VCT 1FFB + # PAT 0 + # CAT 1 + # set the msg_counter for this pid + if _pid not in self.msg_counter.keys(): + self.msg_counter[_pid] = 0 + + sync = 0x47400000 # 1195376640 # 4740 0000 + pid_shifted = _pid << 8 + msg_int = sync | pid_shifted | (self.msg_counter[_pid] + 16) # x10 + msg = struct.pack('>I', msg_int) + b'\x00' + self.msg_counter[_pid] += 1 + if self.msg_counter[_pid] > 15: + self.msg_counter[_pid] = 0 + return msg + + def gen_multiple_string_structure(self, _names): + # Table 6.39 Multiple String Structure + # event titles, long channel names, the ETT messages, and RRT text items + # allows for upto 255 character strings + # 8bit = array size + # for each name + # 3byte ISO_639_language_string = 'eng' + # 1byte segments = normally 0x01 + # for each segment + # 1byte compression = 0x00 (not compression) + # 1byte mode = 0x00 (used with unicode 2 byte letters, assume ASCII) + # 1byte length in bytes = calculated + # string in byte format. 1 byte per character + msg = utils.set_u8(len(_names)) + for name in _names: + lang = self.gen_lang(b'eng') + segment_len = utils.set_u8(1) + compress_mode = utils.set_u16(0) + name_bytes = utils.set_str(name.encode(), False) + msg += lang + segment_len + compress_mode + name_bytes + return msg + + def gen_channel_longnames(self, names): + # Table 6.28 Extended Channel Name Descriptor + # channel name for the virtual channel containing this descriptor + # allows for upto 245 character strings + # 8bit = tag = 0xA0 + # 8bit = description length + # long_channel_name_text = gen_multiple_string_structure() + long_name = self.gen_multiple_string_structure(names) + return ATSC_EXTENDED_CHANNEL_DESCR_TAG + utils.set_u8(len(long_name)) + long_name + + def gen_vct_channel_descriptor(self, xxx): + # 111111 static bits + # 10bits description_length = long channel name length + # a list of descriptor() possible descriptors listed in Table 6.25a + + # expected descriptors + # A0 = channel long names + # A1 = Service Location + pass + + def gen_pid(self, _prog_number): + # the PID is based on the program number and returns an integer (not bytes) + # 30,40,50,60,130,140,150,160,230,... + # Base_PID = prog_num << 4 + # Video Stream Element = Base_PID + 1 with the 12th bit set + # Audio Stream Elements = Vide Stream PID + 4 with the 12th bit set, then +1 for each additional lang + pid_lookup = [0x00, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, + 0x130, 0x140, 0x150, 0x160, 0x170, 0x180, 0x190, 0x230, 0x240] + return pid_lookup[_prog_number] + + def gen_lang(self, _name): + return struct.pack('%ds' % (len(_name)), _name) + + def update_sdt_names(self, _video, _service_provider, _service_name): + if _video.data is None: + return + i = 0 + video_len = len(_video.data) + msg = None + while True: + if i + ATSC_MSG_LEN > video_len: + break + packet = _video.data[i:i + ATSC_MSG_LEN] + program_fields = self.decode_ts_packet(packet) + if program_fields is None: + i += ATSC_MSG_LEN + continue + if program_fields['transport_error_indicator']: + i += ATSC_MSG_LEN + continue + if program_fields['pid'] == 0x0011: + descr = b'\x01' \ + + utils.set_str(_service_provider, False) \ + + utils.set_str(_service_name, False) + descr = b'\x48' + utils.set_u8(len(descr)) + descr + msg = packet[8:20] + utils.set_u8(len(descr)) + descr + length = utils.set_u16(len(msg) + 4 + 0xF000) + msg = ATSC_SERVICE_DESCR_TABLE_TAG + length + msg + crc = self.gen_crc_mpeg(msg) + msg = packet[:5] + msg + crc + msg = msg.ljust(len(packet), b'\xFF') + _video.data = b''.join([ + _video.data[:i], + msg, + _video.data[i + ATSC_MSG_LEN:] + ]) + i += ATSC_MSG_LEN + if msg is None: + self.logger.debug('Missing ATSC SDT Msg in stream, unable to update provider and service name') + else: + self.logger.debug('Updating ATSC SDT with service info {} {}' \ + .format(_service_provider, _service_name)) + + def gen_sld(self, _base_pid, _elements): + # Table 6.29 Service Location Descriptor + # Appears in each channel in the VCT + # 8bit = tag = 0xA1 + # 8bit = description length + # 3bits = 111 static bits + # 13bits = PCR_PID = 0x1FFF or the program ID value found in the TS_prog_map + # 8bits = number of elements + # for each element + # 8bits = stream_type = 0x02 (video stream) or 0x81 (audio stream) + # 3bits = 111 static bits + # 13bits = PCR_PID = (same as abovefor video stream) unique for audio + # 8*3bytes = lang (spa, eng, mul, null for video stream + # + # element is an array of languages + + # the PID is based on the program number. + # Base_PID = prog_num << 4 + # Video Stream Element = Base_PID + 1 with the 12th bit set + # Audio Stream Elements = Vide Stream PID + 4 with the 12th bit set, then +1 for each additional lang + + elem_len = utils.set_u8(len(_elements) + 1) + video_pid = utils.set_u16(_base_pid + 1 + 57344) # E000 + stream_type = b'\x02' + lang_0 = b'\x00\x00\x00' + msg = stream_type + video_pid + lang_0 + stream_type = b'\x81' + audio_pid_int = _base_pid + 3 + 57344 # E000 (starts at 0x34 and increments by 1) + for lang in _elements: + audio_pid_int += 1 + audio_pid = utils.set_u16(audio_pid_int) + lang_msg = struct.pack('%ds' % (len(lang)), + lang.encode()) + msg += stream_type + audio_pid + lang_msg + msg = video_pid + elem_len + msg + length = utils.set_u8(len(msg)) + return ATSC_SERVICE_LOCATION_DESCR_TAG + length + msg + + def gen_vct_channel(self, _tsid, _short_name, _channel): + # Channel part of Table 6.4 Terrestrial Virtual Channel Table + # 7*2byte characters short name = dict key + # 1111 static bits + # 10bits major channel number + # 10bits minor channel number + # 1byte modulation_mode = 0x04 + # 4bytes carrier_freq = 0 + # 2bytes channel_tsid = same as VCT TSID + # 2bytes program number = index from 1 to n + # 2bits ETM_location = 00 (no location) + # 1bit access_control = 0 + # 1bit hidden = 0 + # 11 static bits + # 1bit hide_guide = 0 + # 111 static bits + # 6bits service_type = 000010 + # 2bytes source_id = index from 1 to n (same as prog_num) + # 111111 static bits + # 10bits description_length = long channel name length + # descriptor() = gen_extended_channel_descriptor(names) + # 111111 static bits + # 10bits additional_description_length = long channel name length = 0 + # no additional descriptions are used + + # chnum_maj + # chnum_min + # prog_num + # long_name + u16name = b'' + short_name7 = _short_name.ljust(7, '\x00') + + for ch in short_name7: + u16name += utils.set_u16(ord(ch)) + ch_num = _channel['chnum_maj'] << 10 + ch_num |= 15728640 # 0xf00000 static bits + ch_num |= _channel['chnum_min'] + u3bch_num = utils.set_u32(ch_num)[1:] + mod_mode = b'\x04' + freq = b'\x00\x00\x00\x00' + prog_num = utils.set_u16(_channel['prog_num']) + pid = self.gen_pid(_channel['prog_num']) + misc_bits = b'\x0d\xc2' # 0000 1101 1100 0010 + source_id = prog_num + descr = _channel['descr'] + descr_msg = b'' + for key in descr.keys(): + if key == 'long_names': + descr_msg += self.gen_channel_longnames(descr[key]) + elif key == 'lang': + descr_msg += self.gen_sld(pid, descr[key]) + descr_len = utils.set_u16(len(descr_msg) + 0xFC00) + + return u16name + u3bch_num + mod_mode + freq + _tsid + prog_num + misc_bits + \ + source_id + descr_len + descr_msg + + def gen_pat_channels(self, _channels): + # for each section (sids): + # 2bytes = program number (non-zero) 1..n + # 3bits = 111 static bits + # 13bits = multiples of 10 30,40,50,60,130,140,150,160,230,... + msg = b'' + for i in range(1, len(_channels) + 1): + pid = utils.set_u16(self.gen_pid(i) + 57344) # E000 + msg += utils.set_u16(i) + pid + return msg + + def gen_pat(self, _mux_stream): + # Table Program Association Table : MPEG-2 protocol + # 1byte table_id = 0x00 + # 1011 static bits + # 12bits length including crc + # 2bytes = tsid (0000 11xx xxxx xxxx) + # 2bits = 11 static bits + # 5bits version_no = 1 + # 1bit current_next_indicator = 1 + # 1byte section_number = 0 (only one section) + # 1byte last_section_number = 0 (only one section) + # gen_pat_channels() + # crc + tsid = _mux_stream['tsid'] + ver_sect = b'\xc3\x00\x00' + channels_len = utils.set_u8(len(_mux_stream['channels'])) + for i in range(len(_mux_stream['channels'])): + pid = self.gen_pid(i) + msg = tsid + ver_sect + self.gen_pat_channels(_mux_stream['channels']) + length = utils.set_u16(len(msg) + 4 + 0xB000) + msg = MPEG2_PROGRAM_ASSOCIATION_TABLE_TAG + length + msg + crc = self.gen_crc_mpeg(msg) + msg = self.gen_header(0) + msg + crc + return self.format_video_packets([msg]) + + def gen_vct(self, _mux_stream): + # Table 6.4 Terrestrial Virtual Channel Table + # 1byte table_id = 0xc8 + # 1111 static bits + # 12bits length including crc + # 2bytes TSID (transport stream id) = 0x0b21 (how is this generated?) + # 11 static bits + # 5bits version_no = 1 + # 1bit current_next_indicator = 1 + # 1byte section_number = 0 (only one section) + # 1bytes last_section_number = 0 (only one section) + # 1byte protocol_version = 0 + # 1byte number of channels + # gen_vct_channel(channel) + # CRC_32 + msg = b'' + tsid = _mux_stream['tsid'] + ver_sect_last_sect_proto = b'\xc3\x00\x00\x00' + channels_len = utils.set_u8(len(_mux_stream['channels'])) + for short_name in _mux_stream['channels'].keys(): + msg += self.gen_vct_channel(tsid, short_name, _mux_stream['channels'][short_name]) + extra_empty_descr = b'\xfc\x00' + msg = tsid + ver_sect_last_sect_proto + channels_len + msg + extra_empty_descr + length = utils.set_u16(len(msg) + 4 + 0xF000) + + msg = ATSC_VIRTUAL_CHANNEL_TABLE_TAG + length + msg + crc = self.gen_crc_mpeg(msg) + msg = self.gen_header(0x1ffb) + msg + crc + + # channels is a dict with the key being the primary channel name (short_name) + return self.format_video_packets([msg]) + + def gen_stt(self): + # Table 6.1 System Time Table + # 1byte table_id = 0xcd + # 1111 static bits + # 12bits length including crc + # 2bytes table id extension = 0x0000 + # 2bits = 11 static bits + # 5bits version_no = 1 + # 1bit current_next_indicator = 1 + # 1byte section_number = 0 (only one section) + # 1byte last_section_number = 0 (only one section) + # 1byte protocol_version = 0 + # 4bytes system time = time since 1980 (GPS) + # 1byte GPS_UTC_offset = 12 (last checked 2021) + # 2bytes daylight_saving = 0x60 + # 1bit ds_status + # 2bits 11 static bits + # 5bits DS day of month + # 8bits DS hour + # CRC_32 + + # 475f fb17 00cd f011 0000 c100 0000 ..G_............ + # 0x01b0: 4d3c e809 1060 00f3 30ca 76 + # 1295837193 + table_id_ext = b'\x00\x00' + ver_sect_proto = b'\xc1\x00\x00\x00' + + time_gps = datetime.datetime.utcnow() - datetime.datetime(1980, 1, 6) \ + - datetime.timedelta(seconds=LEAP_SECONDS_2021 - LEAP_SECONDS_1980) + time_gps_sec = int(time_gps.total_seconds()) + system_time = utils.set_u32(time_gps_sec) + delta_time = utils.set_u8(LEAP_SECONDS_2021 - LEAP_SECONDS_1980) + daylight_savings = b'\x60' + + msg = table_id_ext + ver_sect_proto + system_time + \ + delta_time + daylight_savings + b'\x00' + length = utils.set_u16(len(msg) + 4 + 0xF000) + msg = MPEG2_PROGRAM_SYSTEM_TIME_TABLE_TAG + length + msg + crc = self.gen_crc_mpeg(msg) + msg = self.gen_header(0x1ffb) + msg + crc + return self.format_video_packets([msg]) + + def gen_pmt(self, _channels): + # Table Program Map Table : MPEG-2 protocol + # + # DATA EXAMPLE + # 0001 b009 ffff c300 00d5 dcfb 4c + # 1byte table_id = 0x02 + # 1011 static bits + # 12bits length including crc + # 2bytes = program number (like 6) + # 2bits = 11 static bits + # 5bits version_no = 1 + # 1bit current_next_indicator = 1 + # 1byte section_number = 0 (only one section) + # 1byte last_section_number = 0 (only one section) + # 3bits = 111 static bits + # 13bits = PCR_PID (like for prog_num 3 = 61. seems to always end in a 1) + # 4bits = 1111 static bits + # 12bits = program_info_length + # for loop of descriptors + # 05 name of the channel (GA94) + # + + msgs = [] + prog_num_int = 0 + for short_name in _channels.keys(): + prog_num_int += 1 + prog_num_bytes = utils.set_u16(prog_num_int) + ver_sect = b'\xc1\x00\x00' + base_pid_int = self.gen_pid(prog_num_int) + pid_video_int = base_pid_int + 1 + pid_video = utils.set_u16(pid_video_int + 0xE000) + pid_audio_int = pid_video_int + 3 + pid_audio = utils.set_u16(pid_audio_int + 0xE000) + descr_prog = b'\xf0\x00' + descr_video = b'\x02' + pid_video + b'\xF0\x00' + descr_audio = b'\x81' + pid_audio + b'\xF0\x00' + msg = prog_num_bytes + ver_sect + pid_video + descr_prog + descr_video + descr_audio + length = utils.set_u16(len(msg) + 4 + 0xB000) + msg = MPEG2_PROGRAM_MAP_TABLE_TAG + length + msg + crc = self.gen_crc_mpeg(msg) + msgs.append(self.gen_header(base_pid_int) + msg + crc) + return [self.format_video_packets(msgs)] + + def gen_mgt(self, _mux_stream): + # Table 6.2 Master Guide Table + # 1byte table_id = 0xc7 + # 1111 static bits + # 12bits length including crc + # 16bits 0x0000 static bits + # 2bits = 11 = static bits + # 5bits version_no = 1 + # 1bit current_next_indicator = 1 + # 1byte section_number = 0 (only one section) + # 1byte last_section_number = 0 (only one section) + # 1byte protocol_version = 0 + # 2bytes number of tables + + msg = ATSC_MASTER_GUIDE_TABLE_TAG + return msg + + def gen_cat(self): + # Table Conditional Access Table : MPEG-2 protocol + # + # DATA EXAMPLE + # 0001 b009 ffff c300 00d5 dcfb 4c + # 1byte table_id = 0x01 + # 1011 static bits + # 12bits length including crc + # 18bits 1111 1111 1111 1111 11 + # 5bits version_no = 1 + # 1bit current_next_indicator = 1 + # 1byte section_number = 0 (only one section) + # 1byte last_section_number = 0 (only one section) + # c10000 (5) c30000 (2,3,4,5,6) d50000 (7) c50000 (3) d30000 (5) + # c70000 (3) dd0000 (4) + # for each section (sids): + # 2bytes = program number (non-zero) + # 3bits = 111 static bits + # 13bits = multiples of 10 + # 0001 e0 30 + # 0002 e0 40 + # 0003 e0 50 + # 0004 e0 60 + # crc + # NOTE: all transmissions had a zero sections transmission + # search 0x0020.*0001 ... + return b'\x00\x01\xb0\x09\xff\xff\xc3\x00\x00\xd5\xdc\xfb\x4c' + + def update_continuity_counter(self, section): + pid = self.get_pid(section) + if pid is None: + return section + + if pid not in self.msg_counter.keys(): + self.msg_counter[pid] = 0 + + s_int = section[3] + s_top = s_int & 0xf0 + + s_int = s_top + self.msg_counter[pid] + sect_ba = bytearray(section) + sect_ba[3] = s_int + sect_bytes = bytes(sect_ba) + + self.msg_counter[pid] += 1 + if self.msg_counter[pid] > 15: + self.msg_counter[pid] = 0 + + return sect_bytes + + + def format_video_packets(self, _msgs=None): + # atsc packets are 1316 in length with 7 188 sections + # each section has a 471f ff10 00 when no data is present + + # finally the controls and msg counter byte is added (10) no msg counter + # for continuation it had 471f fb1b 0000 previously 475f fb1a + # 471f fb15 00 475f fb14 00 (c8 or c7) + # + # pid = PMT channel pid + # STT 1FFB + # VCT 1FFB + # PAT 0 + # CAT 1 + # 7 sections per packet + sections = [ + self.update_continuity_counter(self.atsc_blank_section), + self.update_continuity_counter(self.atsc_blank_section), + self.update_continuity_counter(self.atsc_blank_section), + self.update_continuity_counter(self.atsc_blank_section), + self.update_continuity_counter(self.atsc_blank_section), + self.update_continuity_counter(self.atsc_blank_section), + self.update_continuity_counter(self.atsc_blank_section), + ] + + if _msgs is None: + return b''.join(sections) + + # for now assume the msgs are less than 1316 + if len(_msgs) > 7: + self.logger.error('ATSC: TOO MANY MESSAGES={}'.format(len(_msgs))) + return None + for i in range(len(_msgs)): + if len(_msgs[i]) > ATSC_MSG_LEN: + self.logger.error('ATSC: MESSAGE LENGTH TOO LONG={}'.format(len(_msgs[i]))) + return None + else: + sections[i] = self.update_continuity_counter(_msgs[i].ljust(ATSC_MSG_LEN, b'\xff')) + + return b''.join(sections) + # TBD need to handle large msg and more than 7 msgs + + def extract_psip(self, _video_data): + packet_list = [] + if _video_data is None: + return + i = 0 + video_len = len(_video_data) + prev_pid = -1 + pmt_pids = None + pat_found = False + pmt_found = False + seg_counter = 0 + + while True: + if i + ATSC_MSG_LEN > video_len: + break + packet = _video_data[i:i + ATSC_MSG_LEN] + i += ATSC_MSG_LEN + program_fields = self.decode_ts_packet(packet) + + seg_counter += 1 + if seg_counter > 7: + # self.logger.debug('###### SENDING BACK {} PACKETS'.format(len(packet_list))) + break + + if program_fields is None: + continue + if program_fields['transport_error_indicator']: + continue + + # SDT: 17, PAT: 0, Private data: 4096 (audio/video meta) + if program_fields['pid'] == 0 \ + or program_fields['pid'] == 4096: + packet_list.append(packet) + + seg_counter += 1 + if seg_counter > 7: + # self.logger.debug('###### SENDING BACK {} PACKETS'.format(len(packet_list))) + break + + continue + + + if program_fields['pid'] == 0x0000: + pmt_pids = self.decode_pat(program_fields['payload']) + # self.logger.debug('###### EXPECTED PMT PIDS: {}'.format(pmt_pids)) + if not pat_found: + packet_list.append(packet) + pat_found = True + #if pmt_pids and program_fields['pid'] in pmt_pids.keys(): + # program = pmt_pids[program_fields['pid']] + # self.decode_pmt(program_fields['pid'], program, program_fields['payload']) + # if not pmt_found: + # # self.logger.debug('###### FOUND PMT PID: {}'.format(program_fields['pid'])) + # packet_list.append(packet) + # pmt_found = True + # continue + #elif program_fields['pid'] == 0x1ffb: + # self.logger.info('Packet Table indicator 0x1ffb, not implemented {}'.format(i)) + # continue + # elif program_fields['pid'] == 0x0011: + # self.logger.info('Service Description Table (SDT) 0x0011, not implemented {}'.format(i)) + # continue + # elif program_fields['pid'] == 0x0000 or \ + # program_fields['pid'] == 0x0100 or \ + # program_fields['pid'] == 0x0101: + # continue + # else: + # self.logger.info('Unknown PID {}'.format(program_fields['pid'])) + prev_pid = program_fields['pid'] + return packet_list + + def sync_audio_video(self, _video_data): + """ + Trims the audio or video to sync the PTS for both + and return the video data with the removed parts + """ + packet_list = [] + if _video_data is None: + return + i = 0 + video_len = len(_video_data) + prev_pid = -1 + pmt_pids = None + pat_found = False + pmt_found = False + seg_counter = 0 + + # print('writing out segment') + # f = open('/tmp/data/segment.ts', 'wb') + # f.write(_video_data) + # f.close() + + while True: + if i + ATSC_MSG_LEN > video_len: + break + packet = _video_data[i:i + ATSC_MSG_LEN] + i += ATSC_MSG_LEN + program_fields = self.decode_ts_packet(packet) + + seg_counter += 1 + if seg_counter > 7: + # self.logger.debug('###### SENDING BACK {} PACKETS'.format(len(packet_list))) + break + else: + packet_list.append(packet) + continue + + if program_fields is None: + continue + if program_fields['transport_error_indicator']: + continue + + if program_fields['pid'] == 0x0000: + pmt_pids = self.decode_pat(program_fields['payload']) + # self.logger.debug('###### EXPECTED PMT PIDS: {}'.format(pmt_pids)) + if not pat_found: + packet_list.append(packet) + pat_found = True + if pmt_pids and program_fields['pid'] in pmt_pids.keys(): + program = pmt_pids[program_fields['pid']] + self.decode_pmt(program_fields['pid'], program, program_fields['payload']) + if not pmt_found: + # self.logger.debug('###### FOUND PMT PID: {}'.format(program_fields['pid'])) + packet_list.append(packet) + pmt_found = True + continue + elif program_fields['pid'] == 0x1ffb: + self.logger.info('Packet Table indicator 0x1ffb, not implemented {}'.format(i)) + continue + # elif program_fields['pid'] == 0x0011: + # self.logger.info('Service Description Table (SDT) 0x0011, not implemented {}'.format(i)) + # continue + # elif program_fields['pid'] == 0x0000 or \ + # program_fields['pid'] == 0x0100 or \ + # program_fields['pid'] == 0x0101: + # continue + # else: + # self.logger.info('Unknown PID {}'.format(program_fields['pid'])) + prev_pid = program_fields['pid'] + return packet_list + + + def get_pid(self, _packet_188): + word = struct.unpack('!I', _packet_188[0:4])[0] + sync = (word & 0xff000000) >> 24 + if sync != 0x47: + return None + + # Packet Identifier, describing the payload data. + pid = (word & 0x1fff00) >> 8 + return pid + + def decode_ts_packet(self, _packet_188): + fields = {} + word = struct.unpack('!I', _packet_188[0:4])[0] + sync = (word & 0xff000000) >> 24 + if sync != 0x47: + return None + + fields['transport_error_indicator'] = (word & 0x800000) != 0 + # Set when a demodulator can't correct errors from FEC data; indicating the packet is corrupt + # print transport_error_indicator + + # Set when a PES, PSI, or DVB-MIP packet begins immediately following the header. + fields['payload_unit_start_indicator'] = (word & 0x400000) != 0 + + # "Set when the current packet has a higher priority than other packets with the same PID. + fields['transport_priority'] = (word & 0x200000) != 0 + + # Packet Identifier, describing the payload data. + fields['pid'] = (word & 0x1fff00) >> 8 + # '00' = Not scrambled. + # For DVB-CSA and ATSC DES only:[8] + # '01' (0x40) = Reserved for future use + # '10' (0x80) = Scrambled with even key + # '11' (0xC0) = Scrambled with odd key + fields['scrambling_control'] = (word & 0xc0) >> 6 + + # 01 – no adaptation field, payload only, + # 10 – adaptation field only, no payload, + # 11 – adaptation field followed by payload, + # 00 - RESERVED for future use [9] + fields['adaptation_field_control'] = (word & 0x30) >> 4 + + if fields['adaptation_field_control'] == 1: + has_adapt = False + has_payload = True + elif fields['adaptation_field_control'] == 2: + has_adapt = True + has_payload = False + elif fields['adaptation_field_control'] == 3: + has_adapt = True + has_payload = True + else: + # 00 - RESERVED for future use + # hence tstools "### Packet PID 14ae has adaptation field control = 0 + # which is a reserved value (no payload, no adaptation field)" + # just assume payload, don't spam, and mark it corrupted + has_adapt = False + has_payload = True + fields['corrupted_adaption_control_field'] = True + + # Sequence number of payload packets (0x00 to 0x0F) within each stream (except PID 8191) + # Incremented per-PID, only when a payload flag is set. + fields['cont_counter'] = word & 0xf + + payload_start = 5 + if has_adapt: + adapt_length = struct.unpack('b', bytes([_packet_188[5]]))[0] + if 6 + adapt_length > len(_packet_188): + return None + + fields['adapt'] = _packet_188[6:6 + adapt_length] + payload_start = 6 + adapt_length + + if has_payload: + fields['payload'] = _packet_188[payload_start:] + else: + # No payload here according to the bitfields - save it anyways + extra = _packet_188[payload_start:] + if len(extra) != 0: + fields['corrupt_payload'] = extra + + return fields + + def decode_pmt(self, pid, program, payload): + t = binascii.b2a_hex(payload) + if t not in self.type_strings: + self.type_strings.append(t) + + pcr_pid = struct.unpack("!H", payload[8:10])[0] + reserved = (pcr_pid & 0xe000) >> 13 + pcr_pid &= 0x1fff + desc1 = payload[12:] + # self.logger.debug('###### PMT DESCR {} {}'.format(pcr_pid, desc1)) + # descriptors = decode_descriptors(desc1) + + def decode_pat(self, payload): + t = binascii.b2a_hex(payload) + if t not in self.type_strings: + self.type_strings.append(t) + + # http://www.etherguidesystems.com/Help/SDOs/MPEG/Syntax/TableSections/Pat.aspx + + section_length = (payload[1] & 0xf << 8) | payload[2] # 12-bit field + program_map_pids = {} + + # after extra fields (transport_stream_id to last_section_num, by size, minus CRC-32 at end + program_count = (section_length - 5) / 4 - 1 + + if section_length > 20: + return program_map_pids + + for i in range(0, int(program_count)): + at = 8 + (i * 4) # skip headers, just get to the program numbers table + program_number = struct.unpack("!H", payload[at:at + 2])[0] + if at + 2 > len(payload): + break + program_map_pid = struct.unpack("!H", payload[at + 2:at + 2 + 2])[0] + + # the pid is only 13 bits, upper 3 bits of this field are 'reserved' (I see 0b111) + reserved = (program_map_pid & 0xe000) >> 13 + program_map_pid &= 0x1fff + + program_map_pids[program_map_pid] = program_number + i += 1 + return program_map_pids diff --git a/lib/streams/ffmpeg_proxy.py b/lib/streams/ffmpeg_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..2fc374194427fb62279ff7d6fb3f1f6c3a9c4d92 --- /dev/null +++ b/lib/streams/ffmpeg_proxy.py @@ -0,0 +1,255 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import errno +import subprocess +import time + +from lib.clients.web_handler import WebHTTPHandler +from lib.streams.video import Video +from lib.db.db_config_defn import DBConfigDefn +from .stream import Stream +from .stream_queue import StreamQueue +from .pts_validation import PTSValidation + +MAX_IDLE_TIMER = 59 + + +class FFMpegProxy(Stream): + + def __init__(self, _plugins, _hdhr_queue): + self.ffmpeg_proc = None + self.last_refresh = None + self.block_prev_time = None + self.buffer_prev_time = None + self.small_pkt_streaming = False + self.block_max_pts = 0 + self.block_prev_pts = 0 + self.prev_last_pts = 0 + self.default_duration = 0 + self.block_moving_avg = 0 + self.channel_dict = None + self.write_buffer = None + self.stream_queue = None + self.pts_validation = None + self.tuner_no = -1 + super().__init__(_plugins, _hdhr_queue) + self.db_configdefn = DBConfigDefn(self.config) + self.video = Video(self.config) + + def update_tuner_status(self, _status): + ch_num = self.channel_dict['display_number'] + namespace = self.channel_dict['namespace'] + scan_list = WebHTTPHandler.rmg_station_scans[namespace] + tuner = scan_list[self.tuner_no] + if type(tuner) == dict and tuner['ch'] == ch_num: + WebHTTPHandler.rmg_station_scans[namespace][self.tuner_no]['status'] = _status + + def stream(self, _channel_dict, _write_buffer, _tuner_no): + global MAX_IDLE_TIMER + self.logger.info('Using ffmpeg_proxy for channel {}'.format(_channel_dict['uid'])) + self.tuner_no = _tuner_no + self.channel_dict = _channel_dict + self.write_buffer = _write_buffer + self.config = self.db_configdefn.get_config() + MAX_IDLE_TIMER = self.config[self.namespace.lower()]['stream-g_stream_timeout'] + + self.pts_validation = PTSValidation(self.config, self.channel_dict) + channel_uri = self.get_stream_uri(self.channel_dict) + if not channel_uri: + self.logger.warning('Unknown Channel {}'.format(_channel_dict['uid'])) + return + self.ffmpeg_proc = self.open_ffmpeg_proc(channel_uri) + time.sleep(0.01) + self.last_refresh = time.time() + self.block_prev_time = self.last_refresh + self.buffer_prev_time = self.last_refresh + self.read_buffer() + while True: + if not self.video.data: + self.logger.info( + 'No Video Data, refreshing stream {} {}' + .format(_channel_dict['uid'], self.ffmpeg_proc.pid)) + self.ffmpeg_proc = self.refresh_stream() + else: + try: + self.validate_stream() + self.update_tuner_status('Streaming') + start_ttw = time.time() + self.write_buffer.write(self.video.data) + delta_ttw = time.time() - start_ttw + self.logger.info( + 'Serving {} {} ({}B) ttw:{:.2f}s' + .format(self.ffmpeg_proc.pid, _channel_dict['uid'], + len(self.video.data), delta_ttw)) + except IOError as e: + if e.errno in [errno.EPIPE, errno.ECONNABORTED, errno.ECONNRESET, errno.ECONNREFUSED]: + self.logger.info('1. Connection dropped by end device {}'.format(self.ffmpeg_proc.pid)) + break + else: + self.logger.error('{}{}'.format( + '1 UNEXPECTED EXCEPTION=', e)) + raise + try: + self.read_buffer() + except exceptions.CabernetException as ex: + self.logger.info('{} {}'.format(ex, self.ffmpeg_proc.pid)) + break + except Exception as e: + self.logger.error('{}{}'.format( + '2 UNEXPECTED EXCEPTION=', e)) + break + self.terminate_stream() + + def validate_stream(self): + if not self.config[self.config_section]['player-enable_pts_filter']: + return + + has_changed = True + while has_changed: + has_changed = False + results = self.pts_validation.check_pts(self.video.data) + if results['byteoffset'] != 0: + if results['byteoffset'] < 0: + self.write_buffer.write(self.video.data[-results['byteoffset']:len(self.video.data) - 1]) + else: + self.write_buffer.write(self.video.data[0:results['byteoffset']]) + has_changed = True + if results['refresh_stream']: + self.ffmpeg_proc = self.refresh_stream() + self.read_buffer() + has_changed = True + if results['reread_buffer']: + self.read_buffer() + has_changed = True + return + + def read_buffer(self): + global MAX_IDLE_TIMER + data_found = False + self.video.data = None + idle_timer = MAX_IDLE_TIMER # time slice segments are less than 10 seconds + while not data_found: + self.video.data = self.stream_queue.read() + if self.video.data: + data_found = True + else: + time.sleep(1) + idle_timer -= 1 + if idle_timer < 1: + idle_timer = MAX_IDLE_TIMER # time slice segments are less than 10 seconds + self.logger.info( + 'No Video Data, refreshing stream {}' + .format(self.ffmpeg_proc.pid)) + self.ffmpeg_proc = self.refresh_stream() + elif int(MAX_IDLE_TIMER / 2) == idle_timer: + self.update_tuner_status('No Reply') + return + + def terminate_stream(self): + self.logger.debug('Terminating ffmpeg stream {}'.format(self.ffmpeg_proc.pid)) + while True: + try: + self.ffmpeg_proc.terminate() + self.ffmpeg_proc.wait(timeout=1.5) + break + except ValueError: + pass + except subprocess.TimeoutExpired: + time.sleep(0.01) + + def refresh_stream(self): + self.last_refresh = time.time() + channel_uri = self.get_stream_uri(self.channel_dict) + self.terminate_stream() + + self.logger.debug('{}{}'.format( + 'Refresh Stream channelUri=', channel_uri)) + ffmpeg_process = self.open_ffmpeg_proc(channel_uri) + # make sure the previous ffmpeg is terminated before exiting + self.buffer_prev_time = time.time() + return ffmpeg_process + + def open_ffmpeg_proc_locast(self, _channel_uri): + """ + ffmpeg drops the first 9 frame/video packets when the program starts. + this means everytime a refresh occurs, 9 frames will be dropped. This is + visible by looking at the video packets for a 6 second window being 171 + instead of 180. Following the first read, the packets increase to 180. + """ + ffmpeg_command = [ + self.config['paths']['ffmpeg_path'], + '-i', str(_channel_uri), + '-f', 'mpegts', + '-nostats', + '-hide_banner', + '-loglevel', 'warning', + '-copyts', + 'pipe:1'] + ffmpeg_process = subprocess.Popen( + ffmpeg_command, + stdout=subprocess.PIPE, + bufsize=-1) + self.stream_queue = StreamQueue(188, ffmpeg_process, self.channel_dict['uid']) + time.sleep(0.1) + return ffmpeg_process + + def open_ffmpeg_proc(self, _channel_uri): + """ + ffmpeg drops the first 9 frame/video packets when the program starts. + this means everytime a refresh occurs, 9 frames will be dropped. This is + visible by looking at the video packets for a 6 second window being 171 + instead of 180. Following the first read, the packets increase to 180. + """ + header = self.channel_dict['json'].get('Header') + str_array = [] + if header: + str_array.append('-headers') + header_value = '' + for key, value in header.items(): + header_value += key+': '+value+'\r\n' + if key == 'Referer': + self.logger.debug('Using HTTP Referer: {} Channel: {}'.format(value, self.channel_dict['uid'])) + str_array.append(header_value) + + ffmpeg_options = [ + '-i', str(_channel_uri), + '-nostats', + '-hide_banner', + '-fflags', '+genpts', + '-threads', '2', + '-loglevel', 'quiet', + '-c', 'copy', + '-f', 'mpegts', + '-c', 'copy', + 'pipe:1'] + + ffmpeg_command = [ + self.config['paths']['ffmpeg_path'] + ] + # Header option must come first in the options list + if str_array: + ffmpeg_command.extend(str_array) + ffmpeg_command.extend(ffmpeg_options) + ffmpeg_process = subprocess.Popen( + ffmpeg_command, + stdout=subprocess.PIPE, + bufsize=-1) + self.stream_queue = StreamQueue(188, ffmpeg_process, self.channel_dict['uid']) + time.sleep(0.1) + return ffmpeg_process diff --git a/lib/streams/internal_proxy.py b/lib/streams/internal_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..4a881b8fa99202b5fe97c1a59bd02da19ca45604 --- /dev/null +++ b/lib/streams/internal_proxy.py @@ -0,0 +1,522 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import datetime +import errno +import os +import queue +import re +import socket +import threading +import time +import urllib.parse +from multiprocessing import Queue, Process + +import lib.common.exceptions as exceptions +import lib.common.utils as utils +import lib.m3u8 as m3u8 +import lib.streams.m3u8_queue as m3u8_queue +from lib.streams.video import Video +from lib.streams.atsc import ATSCMsg +from lib.streams.thread_queue import ThreadQueue +from lib.db.db_config_defn import DBConfigDefn +from lib.db.db_channels import DBChannels +from lib.clients.web_handler import WebHTTPHandler +from .stream import Stream + +MAX_OUT_QUEUE_SIZE = 30 +IDLE_COUNTER_MAX = 110 # four times the timeout * retries to terminate the stream in seconds set in config! +STARTUP_IDLE_COUNTER = 40 # time to wait for an initial stream +# code assumes a timeout response in TVH of 15 or higher. + +class InternalProxy(Stream): + + is_m3u8_starting = 0 + + def __init__(self, _plugins, _hdhr_queue): + global MAX_OUT_QUEUE_SIZE + self.last_refresh = None + self.channel_dict = None + self.wfile = None + self.file_filter = None + self.t_m3u8 = None + self.t_m3u8_pid = None + self.duration = 6 + self.last_ts_filename = '' + super().__init__(_plugins, _hdhr_queue) + self.db_configdefn = DBConfigDefn(self.config) + self.db_channels = DBChannels(self.config) + self.video = Video(self.config) + self.atsc = ATSCMsg() + self.initialized_psi = False + self.in_queue = Queue() + self.t_queue = None + self.out_queue = queue.Queue(maxsize=MAX_OUT_QUEUE_SIZE) + self.terminate_queue = None + self.tc_match = re.compile(r'^.+\D+(\d*)\.ts') + self.idle_counter = 0 + self.tuner_no = -1 + # last time the idle counter was reset + self.last_reset_time = datetime.datetime.now() + self.last_atsc_msg = 0 + self.filter_counter = 0 + self.is_starting = True + self.cue = False + + def terminate(self, *args): + self.t_queue.del_thread(threading.get_ident()) + time.sleep(0.01) + self.in_queue.put({'thread_id': threading.get_ident(), 'uri': 'terminate'}) + time.sleep(0.01) + + # since t_m3u8 has been told to terminate, clear the + # out queue and then wait for t_m3u8, so it can clean up ffmpeg + + # queue is not guaranteed to have terminate, so let t_queue know this thread is ending + count = 10 + while str(self.t_queue) == '0' and self.t_queue.is_alive() and count > 0: + time.sleep(1.0) + count -= 1 + + if not self.t_queue.is_alive(): + self.t_m3u8.join(timeout=15) + if self.t_m3u8.is_alive(): + # this is not likely but if t_m3u8 does not self terminate then force it to terminate + self.logger.debug( + 'm3u8 queue failed to self terminate. Forcing it to terminate {}' + .format(self.t_m3u8_pid)) + self.clear_queues() + self.t_m3u8.terminate() + self.t_m3u8 = None + self.clear_queues() + + def stream(self, _channel_dict, _wfile, _terminate_queue, _tuner_no): + """ + Processes m3u8 interface without using ffmpeg + """ + global IDLE_COUNTER_MAX + self.tuner_no = _tuner_no + self.config = self.db_configdefn.get_config() + IDLE_COUNTER_MAX = self.config[self.namespace.lower()]['stream-g_stream_timeout'] + + self.channel_dict = _channel_dict + if not self.start_m3u8_queue_process(): + self.terminate() + return + self.wfile = _wfile + self.terminate_queue = _terminate_queue + while True: + try: + self.check_termination() + self.play_queue() + if self.t_m3u8 and not self.t_m3u8.is_alive(): + break + except IOError as ex: + # Check we hit a broken pipe when trying to write back to the client + if ex.errno in [errno.EPIPE, errno.ECONNABORTED, errno.ECONNRESET, errno.ECONNREFUSED]: + # Normal process. Client request end of stream + self.logger.info( + 'Connection dropped by end device {} {}' + .format(ex, self.t_m3u8_pid)) + break + else: + self.logger.error('{}{} {} {}'.format( + 'UNEXPECTED EXCEPTION=', ex, self.t_m3u8_pid, socket.getdefaulttimeout())) + raise + except exceptions.CabernetException as ex: + self.logger.info('{} {}'.format(ex, self.t_m3u8_pid)) + break + self.terminate() + + def check_termination(self): + if not self.terminate_queue.empty(): + raise exceptions.CabernetException("Termination Requested") + + def clear_queues(self): + """ + out_queue cannot be closed since it is a normal queue. + The others are handled elsewhere + """ + pass + + def play_queue(self): + global MAX_OUT_QUEUE_SIZE + global IDLE_COUNTER_MAX + + if not self.cue: + self.update_idle_counter() + if self.is_starting and self.idle_counter > STARTUP_IDLE_COUNTER: + # we need to terminate this feed. Some providers require a + # retry in order to make it work. + self.idle_counter = 0 + self.last_reset_time = datetime.datetime.now() + self.last_atsc_msg = 0 + self.logger.info( + '1 Provider has not started playing the stream. Terminating the connection {}' + .format(self.t_m3u8_pid)) + raise exceptions.CabernetException( + '2 Provider has not started playing the stream. Terminating the connection {}' + .format(self.t_m3u8_pid)) + elif self.idle_counter > self.filter_counter + IDLE_COUNTER_MAX: + self.idle_counter = 0 + self.last_atsc_msg = 0 + self.last_reset_time = datetime.datetime.now() + self.filter_counter = 0 + self.logger.info( + '1 Provider has stop playing the stream. Terminating the connection {}' + .format(self.t_m3u8_pid)) + raise exceptions.CabernetException( + '2 Provider has stop playing the stream. Terminating the connection {}' + .format(self.t_m3u8_pid)) + elif self.idle_counter > self.last_atsc_msg+6 \ + and self.is_starting: + self.last_atsc_msg = self.idle_counter + self.write_atsc_msg() + elif self.idle_counter > self.last_atsc_msg+14: + self.last_atsc_msg = self.idle_counter + self.update_tuner_status('No Reply') + self.logger.debug('1 Requesting status from m3u8_queue {}'.format(self.t_m3u8_pid)) + self.in_queue.put({'thread_id': threading.get_ident(), 'uri': 'status'}) + if not self.is_starting \ + and self.config[self.channel_dict['namespace'].lower()] \ + ['player-send_atsc_keepalive']: + self.write_atsc_msg() + while True: + try: + out_queue_item = self.out_queue.get(timeout=1) + except queue.Empty: + break + if out_queue_item['atsc'] is not None: + self.channel_dict['atsc'] = out_queue_item['atsc'] + self.db_channels.update_channel_atsc( + self.channel_dict) + uri = out_queue_item['uri'] + if uri == 'terminate': + raise exceptions.CabernetException( + 'm3u8 queue termination requested, aborting stream {} {}' + .format(self.t_m3u8_pid, threading.get_ident())) + elif uri == 'running': + self.logger.debug('1 Status of Running returned from m3u8_queue {}'.format(self.t_m3u8_pid)) + continue + elif uri == 'extend': + self.logger.debug('Extending the idle timeout to {} seconds'.format(self.idle_counter+IDLE_COUNTER_MAX)) + self.filter_counter = self.idle_counter + continue + data = out_queue_item['data'] + if data['cue'] == 'in': + self.cue = False + self.logger.debug('Turning M3U8 cue to False') + elif data['cue'] == 'out': + self.cue = True + self.logger.debug('Turning M3U8 cue to True') + if data['filtered']: + self.process_filtered_packet(uri) + else: + self.video.data = out_queue_item['stream'] + if self.video.data is not None: + self.idle_counter = 0 + self.last_atsc_msg = 0 + self.last_reset_time = datetime.datetime.now() + self.filter_counter = 0 + if self.config['stream']['update_sdt']: + self.atsc.update_sdt_names(self.video, + self.channel_dict['namespace'].encode(), + self.set_service_name(self.channel_dict).encode()) + self.duration = data['duration'] + uri_decoded = urllib.parse.unquote(uri) + if self.check_ts_counter(uri_decoded): + # if the length of the video is tiny, then print the string out + if len(self.video.data) < 2000 and len(self.video.data) % 188 != 0 or self.video.data.startswith(b'<'): + self.logger.info('{} {} Not a Video packet, restarting HTTP Session, data: {} {}' + .format(self.t_m3u8_pid, uri_decoded, len(self.video.data), self.video.data)) + self.update_tuner_status('Bad Data') + self.in_queue.put({'thread_id': threading.get_ident(), 'uri': 'restart_http'}) + else: + start_ttw = time.time() + self.write_buffer(self.video.data) + delta_ttw = time.time() - start_ttw + self.update_tuner_status('Streaming') + self.logger.info( + 'Serving {} {} ({})s ({}B) ttw:{:.2f}s {}' + .format(self.t_m3u8_pid, uri_decoded, self.duration, + len(self.video.data), delta_ttw, threading.get_ident())) + self.is_starting = False + time.sleep(0.1) + else: + if not self.is_starting: + self.update_tuner_status('No Reply') + uri_decoded = urllib.parse.unquote(uri) + self.logger.debug( + 'No Video Stream from Provider {} {}' + .format(self.t_m3u8_pid, uri_decoded)) + self.check_termination() + time.sleep(0.01) + self.video.terminate() + + def process_filtered_packet(self, _uri): + """ + Assumes the queued item has been pulled and is a filtered item. + """ + self.last_atsc_msg = self.idle_counter + self.filter_counter = self.idle_counter + self.logger.info('Filtered Msg {} {}'.format(self.t_m3u8_pid, urllib.parse.unquote(_uri))) + self.update_tuner_status('Filtered') + # self.write_buffer(out_queue_item['stream']) + if self.is_starting: + self.is_starting = False + self.write_atsc_msg() + self.logger.debug('2 Requesting Status from m3u8_queue {}'.format(self.t_m3u8_pid)) + self.in_queue.put({'thread_id': threading.get_ident(), 'uri': 'status'}) + time.sleep(0.5) + + def write_buffer(self, _data): + """ + Plan is to slowly push out bytes until something is + added to the queue to process. This should stop the + clients from terminating the data stream due to lack of data for + a short. It is currently set to at least 20 seconds of data + before it stops transmitting + """ + try: + bytes_written = 0 + count = 0 + bytes_per_write = int(len(_data)/25) # number of seconds to keep transmitting + while self.out_queue.qsize() < 1 or \ + (self.out_queue.qsize() > 0 and \ + self.out_queue.queue[0]['data'] is not None and \ + self.out_queue.queue[0]['data']['filtered']): + self.wfile.flush() + # Do not use chunk writes! Just send data. + # x = self.wfile.write('{}\r\n'.format(len(_data)).encode()) + next_buffer_write = bytes_written + bytes_per_write + if next_buffer_write >= len(_data): + x = self.wfile.write(_data[bytes_written:]) + bytes_written = len(_data) + self.wfile.flush() + break + else: + count += 1 + if count > 13: + self.update_tuner_status('No Reply') + x = self.wfile.write(_data[bytes_written:next_buffer_write]) + bytes_written = next_buffer_write + # x = self.wfile.write('\r\n'.encode()) + self.wfile.flush() + time.sleep(1.0) + # special filtered packet processing + if self.out_queue.qsize() > 0 and \ + self.out_queue.queue[0]['data'] is not None and \ + self.out_queue.queue[0]['data']['filtered']: + # pull queue item and check to confirm it is filtered + try: + out_queue_item = self.out_queue.get(timeout=1) + except queue.Empty: + # no queue item. Should not happen + self.logger.warning('Unexpected Error: Expected filtered packet, but found no items in queue') + continue + if out_queue_item['data']['filtered']: + self.process_filtered_packet(out_queue_item['uri']) + bytes_per_write = 752 # change writes to a + # # small number so it does not exit during the filtered packets + else: + # somehow NOT filtered, log this issue + self.logger.warning('Unexpected Error: Found unfiltered packet when a filtered packet was expected') + if bytes_written != len(_data): + x = self.wfile.write(_data[bytes_written:]) + self.wfile.flush() + except socket.timeout: + raise + except IOError: + raise + return x + + def write_atsc_msg(self): + if not self.channel_dict['atsc']: + self.logger.debug( + 'No video data, Sending Empty ATSC Msg {}' + .format(self.t_m3u8_pid)) + self.write_buffer( + self.atsc.format_video_packets()) + else: + self.logger.debug( + 'No video data, Sending Default ATSC Msg for channel {}' + .format(self.t_m3u8_pid)) + self.write_buffer( + self.atsc.format_video_packets( + self.channel_dict['atsc'])) + + def get_ts_counter(self, _uri): + m = self.tc_match.findall(_uri) + if len(m) == 0: + return '', 0 + else: + self.logger.debug('ts_counter {} {}'.format(m, _uri)) + x_tuple = m[len(m) - 1] + if len(x_tuple) == 0: + x_tuple = (_uri, '0') + else: + x_tuple = (_uri, x_tuple) + return x_tuple + + def update_tuner_status(self, _status): + ch_num = self.channel_dict['display_number'] + namespace = self.channel_dict['namespace'] + scan_list = WebHTTPHandler.rmg_station_scans[namespace] + tuner = scan_list[self.tuner_no] + if type(tuner) == dict and tuner['ch'] == ch_num: + WebHTTPHandler.rmg_station_scans[namespace][self.tuner_no]['status'] = _status + + def update_idle_counter(self): + """ + Updates the idle_counter to the nearest int in seconds + based on when it was last reset + """ + current_time = datetime.datetime.now() + delta_time = current_time - self.last_reset_time + self.idle_counter = int(delta_time.total_seconds()) + + def check_ts_counter(self, _uri): + """ + Providers sometime add the same stream section back into the list. + This methods catches this and informs the caller that it should be ignored. + """ + # counter = self.tc_match.findall(uri_decoded) + # if len(counter) != 0: + # counter = counter[0] + # else: + # counter = -1 + # self.logger.debug('ts counter={}'.format(counter)) + if _uri == self.last_ts_filename: + self.logger.notice( + 'TC Counter Same section being transmitted, ignoring uri: {} m3u8pid:{} proxypid:{}' + .format(_uri, self.t_m3u8_pid, os.getpid())) + return False + self.last_ts_filename = _uri + return True + + def start_m3u8_queue_process(self): + """ + Python sometimes starts a process where it is not connected to the parent, + so the queues do not interact. The process is killed and restarted + until python can do this correctly. + """ + is_running = False + max_tries = 40 + restarts = 5 + while True: + while InternalProxy.is_m3u8_starting != 0: + time.sleep(0.1) + InternalProxy.is_m3u8_starting = threading.get_ident() + time.sleep(0.01) + if InternalProxy.is_m3u8_starting == threading.get_ident(): + break + ch_num = self.channel_dict['display_number'] + namespace = self.channel_dict['namespace'] + scan_list = WebHTTPHandler.rmg_station_scans[namespace] + tuner = scan_list[self.tuner_no] + m3u8_out_queue = None + + if isinstance(tuner, dict) \ + and tuner['ch'] == ch_num \ + and tuner['instance'] == self.instance: + + if not tuner['mux']: + # new tuner case + m3u8_out_queue = Queue(maxsize=MAX_OUT_QUEUE_SIZE) + self.t_queue = ThreadQueue(m3u8_out_queue, self.config) + self.t_queue.add_thread(threading.get_ident(), self.out_queue) + self.t_queue.status_queue = self.in_queue + WebHTTPHandler.rmg_station_scans[namespace][self.tuner_no]['mux'] = self.t_queue + else: + # reuse tuner case + self.t_queue = tuner['mux'] + self.t_queue.add_thread(threading.get_ident(), self.out_queue) + self.t_m3u8 = self.t_queue.remote_proc + self.t_m3u8_pid = self.t_queue.remote_proc.pid + self.in_queue = self.t_queue.status_queue + + while not is_running and restarts > 0: + restarts -= 1 + # Process is not thread safe. Must do the same target, one at a time. + self.in_queue.put({'thread_id': threading.get_ident(), 'uri': 'status'}) + self.logger.debug('3 Requesting status from m3u8_queue {}'.format(self.t_m3u8_pid)) + + if m3u8_out_queue: + self.logger.debug('Starting m3u8 queue process') + self.t_m3u8 = Process(target=m3u8_queue.start, args=( + self.config, self.plugins, self.in_queue, m3u8_out_queue, self.channel_dict,)) + self.t_m3u8.start() + self.t_queue.remote_proc = self.t_m3u8 + self.t_m3u8_pid = self.t_m3u8.pid + + time.sleep(0.1) + tries = 0 + while self.out_queue.empty() and tries < max_tries: + tries += 1 + time.sleep(0.2) + if tries >= max_tries: + self.m3u8_terminate() + else: + try: + # queue is not empty, but it sticks here anyway... + status = self.out_queue.get(False, 3) + except queue.Empty: + self.m3u8_terminate() + continue + + if status['uri'] == 'terminate': + self.logger.debug('Receive request to terminate from m3u8_queue {}'.format(self.t_m3u8_pid)) + InternalProxy.is_m3u8_starting = False + return False + elif status['uri'] == 'running': + self.logger.debug('2 Status of Running returned from m3u8_queue {}'.format(self.t_m3u8_pid)) + is_running = True + else: + self.logger.warning( + 'Unknown response from m3u8queue: {}' + .format(status['uri'])) + else: + is_running = True + + InternalProxy.is_m3u8_starting = False + return restarts > 0 + + def m3u8_terminate(self): + while not self.in_queue.empty(): + try: + self.in_queue.get() + time.sleep(0.1) + except (queue.Empty, EOFError): + pass + if self.t_m3u8: + self.t_m3u8.terminate() + self.t_m3u8.join() + self.logger.debug( + 'm3u8_queue did not start correctly, restarting {}' + .format(self.channel_dict['uid'])) + try: + while not self.out_queue.empty(): + self.out_queue.get() + except (queue.Empty, EOFError): + pass + self.clear_queues() + time.sleep(0.1) + self.in_queue = Queue() + self.out_queue = queue.Queue(maxsize=MAX_OUT_QUEUE_SIZE) + self.t_queue.add_thread(threading.get_ident(), self.out_queue) + self.t_queue.status_queue = self.in_queue diff --git a/lib/streams/m3u8_queue.py b/lib/streams/m3u8_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..a9ab527827a6d4ac7abee8e36b5c2141e5c9f48f --- /dev/null +++ b/lib/streams/m3u8_queue.py @@ -0,0 +1,868 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import os +import re +import requests +import socket +import sys +import threading +import time +import urllib.parse +from collections import OrderedDict +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend +from multiprocessing import Queue +from queue import Empty +from threading import Thread + +import lib.common.utils as utils +import lib.m3u8 as m3u8 +from lib.common.decorators import handle_url_except +from lib.common.decorators import handle_json_except +from lib.streams.atsc import ATSCMsg +from lib.streams.video import Video +from .pts_validation import PTSValidation +from .pts_resync import PTSResync + + +PLAY_LIST = OrderedDict() +PROCESSED_URLS = {} +IN_QUEUE = Queue() +OUT_QUEUE = Queue() +TERMINATE_REQUESTED = False +MAX_STREAM_QUEUE_SIZE = 20 +STREAM_QUEUE = Queue() +OUT_QUEUE_LIST = [] +HTTP_TIMEOUT=8 +HTTP_RETRIES=3 +MAINTAIN_HTTP_SESSION=True +PARALLEL_DOWNLOADS=3 +IS_VOD = False +UID_COUNTER = 1 +UID_PROCESSED = 1 + +class M3U8GetUriData(Thread): + def __init__(self, _queue_item, _uid_counter, _config): + global TERMINATE_REQUESTED + Thread.__init__(self) + self.queue_item = _queue_item + self.uid_counter = _uid_counter + self.video = Video(_config) + self.config = _config + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) + self.pts_validation = None + if _config[M3U8Queue.config_section]['player-enable_pts_filter']: + self.pts_validation = PTSValidation(_config, M3U8Queue.channel_dict) + if not TERMINATE_REQUESTED: + self.start() + + def run(self): + global UID_COUNTER + global UID_PROCESSED + global STREAM_QUEUE + global TERMINATE_REQUESTED + self.logger.trace('M3U8GetUriData started {} {} {}'.format(self.queue_item['data']['uri'], os.getpid(), threading.get_ident())) + m3u8_data = self.process_m3u8_item(self.queue_item) + if not TERMINATE_REQUESTED: + PROCESSED_URLS[self.uid_counter] = m3u8_data + STREAM_QUEUE.put({'uri_dt': 'check_processed_list'}) + self.logger.trace('M3U8GetUriData terminated COUNTER {} {} {}'.format(self.uid_counter, os.getpid(), threading.get_ident())) + m3u8_data = None + self.queue_item = None + self.uid_counter = None + self.video = None + self.pts_validation = None + self.logger = None + + + @handle_url_except() + def get_uri_data(self, _uri, _retries): + """ + _retries is used by the decorator when a HTTP failure occurs + """ + global HTTP_TIMEOUT + global MAINTAIN_HTTP_SESSION + if not MAINTAIN_HTTP_SESSION: + M3U8Queue.http_session.close() + time.sleep(0.01) + M3U8Queue.http_session = requests.session() + self.logger.trace('HTTP HEADER: {}'.format(M3U8Queue.http_header)) + resp = M3U8Queue.http_session.get(_uri, headers=M3U8Queue.http_header, timeout=HTTP_TIMEOUT, verify=False) + x = resp.content + resp.raise_for_status() + return x + + def decrypt_stream(self, _data): + global HTTP_RETRIES + if _data['key'] and _data['key']['uri']: + if _data['key']['uri'] in M3U8Queue.key_list.keys(): + key_data = M3U8Queue.key_list[_data['key']['uri']] + self.logger.debug('Reusing key {} {}'.format(os.getpid(), _data['key']['uri'])) + elif not _data['key']['uri'].startswith('http'): + self.logger.warning('Unknown protocol, aborting {} {}'.format(os.getpid(), _data['key']['uri'])) + return False + else: + key_uri = _data['key']['uri'] + key_data = self.get_uri_data(key_uri, HTTP_RETRIES) + + if key_data is not None: + M3U8Queue.key_list[_data['key']['uri']] = key_data + if _data['key']['iv'] is None: + # if iv is none, use a random value + iv = bytearray.fromhex('000000000000000000000000000000F6') + elif _data['key']['iv'].startswith('0x'): + iv = bytearray.fromhex(_data['key']['iv'][2:]) + else: + iv = bytearray.fromhex(_data['key']['iv']) + cipher = Cipher(algorithms.AES(key_data), modes.CBC(iv), default_backend()) + decryptor = cipher.decryptor() + self.video.data = decryptor.update(self.video.data) + if len(M3U8Queue.key_list.keys()) > 20: + del M3U8Queue.key_list[list(M3U8Queue.key_list)[0]] + return True + + def atsc_processing(self): + if not M3U8Queue.atsc: + p_list = M3U8Queue.atsc_msg.extract_psip(self.video.data) + if len(p_list) != 0: + M3U8Queue.atsc = p_list + M3U8Queue.channel_dict['atsc'] = p_list + M3U8Queue.initialized_psi = True + return p_list + + elif not M3U8Queue.initialized_psi: + p_list = M3U8Queue.atsc_msg.extract_psip(self.video.data) + if len(M3U8Queue.atsc) < len(p_list): + M3U8Queue.atsc = p_list + M3U8Queue.channel_dict['atsc'] = p_list + M3U8Queue.initialized_psi = True + return p_list + if len(M3U8Queue.atsc) == len(p_list): + for i in range(len(p_list)): + if p_list[i][4:] != M3U8Queue.atsc[i][4:]: + M3U8Queue.atsc = p_list + M3U8Queue.channel_dict['atsc'] = p_list + M3U8Queue.initialized_psi = True + is_changed = True + return p_list + return None + + def is_pts_valid(self): + if self.pts_validation is None: + return True + results = self.pts_validation.check_pts(self.video) + if results['byteoffset'] != 0: + return False + if results['refresh_stream']: + return False + if results['reread_buffer']: + return False + return True + + def get_stream_from_atsc(self): + if M3U8Queue.atsc is not None: + return M3U8Queue.atsc_msg.format_video_packets(M3U8Queue.atsc) + else: + self.logger.info(''.join([ + 'No ATSC msg available during filtered content, ', + 'recommend running this channel again to catch the ATSC msg.'])) + return M3U8Queue.atsc_msg.format_video_packets() + + def process_m3u8_item(self, _queue_item): + global IS_VOD + global TERMINATE_REQUESTED + global PLAY_LIST + global OUT_QUEUE + global UID_PROCESSED + global HTTP_RETRIES + uri_dt = _queue_item['uri_dt'] + data = _queue_item['data'] + if data['filtered']: + PLAY_LIST[uri_dt]['played'] = True + return {'uri': data['uri'], + 'data': data, + 'stream': self.get_stream_from_atsc(), + 'atsc': None} + else: + if IS_VOD: + count = self.config['stream']['vod_retries'] + else: + count = 1 + while count > 0: + self.video.data = self.get_uri_data(data['uri'], HTTP_RETRIES) + if self.video.data: + break + + # TBD WHAT TO DO WITH THIS? + if count > 1: + out_queue_put({'uri': 'extend', + 'data': data, + 'stream': None, + 'atsc': None}) + count -= 1 + + if uri_dt not in PLAY_LIST.keys(): + self.logger.debug('{} uri_dt not in PLAY_LIST keys {}'.format(os.getpid(), uri_dt)) + return + if self.video.data is None: + PLAY_LIST[uri_dt]['played'] = True + return {'uri': data['uri'], + 'data': data, + 'stream': None, + 'atsc': None + } + if not self.decrypt_stream(data): + # terminate if stream is not decryptable + TERMINATE_REQUESTED = True + M3U8Queue.pts_resync.terminate() + M3U8Queue.pts_resync = None + clear_queues() + PLAY_LIST[uri_dt]['played'] = True + return {'uri': 'terminate', + 'data': data, + 'stream': None, + 'atsc': None} + if not self.is_pts_valid(): + PLAY_LIST[uri_dt]['played'] = True + return {'uri': data['uri'], + 'data': data, + 'stream': None, + 'atsc': None + } + + atsc_default_msg = self.atsc_processing() + PLAY_LIST[uri_dt]['played'] = True + if self.uid_counter > UID_PROCESSED+1: + out_queue_put({'uri': 'extend', + 'data': data, + 'stream': None, + 'atsc': None}) + return {'uri': data['uri'], + 'data': data, + 'stream': self.video.data, + 'atsc': atsc_default_msg + } + + +class M3U8Queue(Thread): + """ + This runs as an independent process (one per stream) to get and process the + data stream as fast as possible and return it to the tuner web server for + output to the client. + """ + is_stuck = None + http_session = requests.session() + http_header = None + key_list = {} + config_section = None + channel_dict = None + pts_resync = None + atsc = None + atsc_msg = None + initialized_psi = False + + + def __init__(self, _config, _channel_dict): + global MAINTAIN_HTTP_SESSION + Thread.__init__(self) + # Disable the CERT unverified warnings + requests.packages.urllib3.disable_warnings() + self.video = Video(_config) + self.q_action = None + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) + self.config = _config + self.namespace = _channel_dict['namespace'].lower() + M3U8Queue.config_section = utils.instance_config_section(_channel_dict['namespace'], _channel_dict['instance']) + M3U8Queue.channel_dict = _channel_dict + MAINTAIN_HTTP_SESSION = self.config[_channel_dict['namespace'].lower()]['stream-g_http_session'] + M3U8Queue.atsc_msg = ATSCMsg() + self.channel_dict = _channel_dict + M3U8Queue.atsc = _channel_dict['atsc'] + if _channel_dict['json'].get('Header') is None: + M3U8Queue.http_header = {'User-agent': utils.DEFAULT_USER_AGENT} + else: + M3U8Queue.http_header = _channel_dict['json']['Header'] + if _channel_dict['json'].get('use_date_on_m3u8_key') is None: + self.use_date_on_key = True + else: + self.use_date_on_key = _channel_dict['json']['use_date_on_m3u8_key'] + + M3U8Queue.pts_resync = PTSResync(_config, self.config_section, _channel_dict['uid']) + self.start() + + + def run(self): + global OUT_QUEUE + global STREAM_QUEUE + global TERMINATE_REQUESTED + global UID_COUNTER + global UID_PROCESSED + global PARALLEL_DOWNLOADS + global PROCESSED_URLS + global IS_VOD + try: + while not TERMINATE_REQUESTED: + queue_item = STREAM_QUEUE.get() + self.q_action = queue_item['uri_dt'] + if queue_item['uri_dt'] == 'terminate': + self.logger.debug('Received terminate from internalproxy {}'.format(os.getpid())) + TERMINATE_REQUESTED = True + + break + elif queue_item['uri_dt'] == 'status': + out_queue_put({'uri': 'running', + 'data': None, + 'stream': None, + 'atsc': None}) + continue + elif queue_item['uri_dt'] == 'check_processed_list': + self.logger.debug('#### Received check_processed_list request {} Received: {} Processed: {} Processed_Queue: {} Incoming_Queue: {}' + .format(os.getpid(), UID_COUNTER, UID_PROCESSED, len(PROCESSED_URLS), STREAM_QUEUE.qsize())) + self.check_processed_list() + continue + + self.logger.debug('**** Running check_processed_list {} Received: {} Processed: {} Processed_Queue: {} Incoming_Queue: {}' + .format(os.getpid(), UID_COUNTER, UID_PROCESSED, len(PROCESSED_URLS), STREAM_QUEUE.qsize())) + self.check_processed_list() + while UID_COUNTER - UID_PROCESSED - len(PROCESSED_URLS) > PARALLEL_DOWNLOADS: + self.logger.debug('Slowed Processing: {} Received: {} Processed: {} Processed_Queue: {} Incoming_Queue: {}' + .format(os.getpid(), UID_COUNTER, UID_PROCESSED, len(PROCESSED_URLS), STREAM_QUEUE.qsize())) + time.sleep(.5) + self.check_processed_list() + if TERMINATE_REQUESTED: + break + self.process_queue = M3U8GetUriData(queue_item, UID_COUNTER, self.config) + if IS_VOD: + time.sleep(0.1) + else: + time.sleep(1.0) + UID_COUNTER += 1 + except (KeyboardInterrupt, EOFError) as ex: + TERMINATE_REQUESTED = True + clear_queues() + if self.pts_resync is not None: + self.pts_resync.terminate() + self.pts_resync = None + time.sleep(0.01) + sys.exit() + except Exception as ex: + TERMINATE_REQUESTED = True + STREAM_QUEUE.put({'uri_dt': 'terminate'}) + IN_QUEUE.put({'uri': 'terminate'}) + if self.pts_resync is not None: + self.pts_resync.terminate() + self.pts_resync = None + clear_queues() + time.sleep(0.01) + self.logger.exception('{}'.format( + 'UNEXPECTED EXCEPTION M3U8Queue=')) + sys.exit() + # we are terminating so cleanup ffmpeg + if self.pts_resync is not None: + self.pts_resync.terminate() + self.pts_resync = None + time.sleep(0.01) + out_queue_put({'uri': 'terminate', + 'data': None, + 'stream': None, + 'atsc': None}) + PROCESSED_URLS.clear() + time.sleep(0.01) + TERMINATE_REQUESTED = True + self.logger.debug('M3U8Queue terminated {}'.format(os.getpid())) + + + def check_processed_list(self): + global UID_PROCESSED + global PROCESSED_URLS + if len(PROCESSED_URLS) > 0: + first_key = sorted(PROCESSED_URLS.keys())[0] + if first_key == UID_PROCESSED: + try: + self.video.data = PROCESSED_URLS[first_key]['stream'] + M3U8Queue.pts_resync.resequence_pts(self.video) + if self.video.data is None and self.q_action != 'check_processed_list': + PLAY_LIST[self.q_action]['played'] = True + PROCESSED_URLS[first_key]['stream'] = self.video.data + out_queue_put(PROCESSED_URLS[first_key]) + del PROCESSED_URLS[first_key] + UID_PROCESSED += 1 + except TypeError as ex: + # If the first_key is null, then skip it and move on. + UID_PROCESSED += 1 + +class M3U8Process(Thread): + """ + process for managing the list of m3u8 data sections. + Includes managing the processing queue and providing + the M3U8Queue with what to process. + """ + + def __init__(self, _config, _plugins, _channel_dict): + global HTTP_TIMEOUT + global HTTP_RETRIES + global PARALLEL_DOWNLOADS + Thread.__init__(self) + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) + self.config = _config + self.channel_dict = _channel_dict + if _channel_dict['json'].get('Header') is None: + self.header = {'User-agent': utils.DEFAULT_USER_AGENT} + else: + self.header = _channel_dict['json']['Header'] + if _channel_dict['json'].get('use_date_on_m3u8_key') is None: + self.use_date_on_key = True + else: + self.use_date_on_key = _channel_dict['json']['use_date_on_m3u8_key'] + + self.ch_uid = _channel_dict['uid'] + self.is_starting = True + self.last_refresh = time.time() + self.plugins = _plugins + HTTP_TIMEOUT = self.config[_channel_dict['namespace'].lower()]['stream-g_http_timeout'] + HTTP_RETRIES = self.config[_channel_dict['namespace'].lower()]['stream-g_http_retries'] + + PARALLEL_DOWNLOADS = self.config[_channel_dict['namespace'].lower()]['stream-g_concurrent_downloads'] + self.config_section = utils.instance_config_section(_channel_dict['namespace'], _channel_dict['instance']) + self.use_full_duplicate_checking = self.config[self.config_section]['player-enable_full_duplicate_checking'] + self.use_pathonly_checking = self.config[self.config_section].get('player-enable_pathonly_checking') + + self.is_running = True + self.duration = 6 + self.m3u8_q = M3U8Queue(_config, _channel_dict) + time.sleep(0.1) + self.file_filter = None + self.start() + + def run(self): + global IS_VOD + global IN_QUEUE + global OUT_QUEUE + global TERMINATE_REQUESTED + + self.stream_uri = self.get_stream_uri() + if not self.stream_uri: + self.logger.warning('Unknown Channel {}'.format(self.ch_uid)) + out_queue_put({'uri': 'terminate', + 'data': None, + 'stream': None, + 'atsc': None}) + time.sleep(0.01) + self.terminate() + self.m3u8_q.join() + TERMINATE_REQUESTED = True + self.logger.debug('1 M3U8Process terminated {}'.format(os.getpid())) + return + else: + out_queue_put({'uri': 'running', + 'data': None, + 'stream': None, + 'atsc': None}) + time.sleep(0.01) + + try: + self.logger.debug('M3U8: {} {}'.format(self.stream_uri, os.getpid())) + if self.config[self.config_section]['player-enable_url_filter']: + stream_filter = self.config[self.config_section]['player-url_filter'] + if stream_filter is not None: + self.file_filter = re.compile(stream_filter) + else: + self.logger.warning('[{}]][player-enable_url_filter]' + ' enabled but [player-url_filter] not set' + .format(self.config_section)) + count = 2 + while not TERMINATE_REQUESTED and count > 0: + added = 0 + removed = 0 + self.logger.debug('Reloading m3u8 stream queue {}'.format(os.getpid())) + playlist = self.get_m3u8_data(self.stream_uri, 2) + if playlist is None: + self.logger.debug('M3U Playlist is None, retrying') + self.sleep(self.duration+0.5) + count = count - 1 + continue + count = 2 + if playlist.playlist_type == 'vod' or self.config[self.config_section]['player-play_all_segments']: + if not IS_VOD: + self.logger.debug('Setting stream type to VOD {}'.format(os.getpid())) + IS_VOD = True + elif IS_VOD: + self.logger.debug('Setting stream type to non-VOD {}'.format(os.getpid())) + IS_VOD = False + removed += self.remove_from_stream_queue(playlist) + added += self.add_to_stream_queue(playlist) + if self.plugins.plugins[self.channel_dict['namespace']].plugin_obj \ + .is_time_to_refresh_ext(self.last_refresh, self.channel_dict['instance']): + self.stream_uri = self.get_stream_uri() + self.logger.debug('M3U8: {} {}' + .format(self.stream_uri, os.getpid())) + self.last_refresh = time.time() + time.sleep(0.3) + elif self.duration > 0.5: + self.sleep(self.duration+0.5) + except Exception as ex: + self.logger.exception('{}'.format( + 'UNEXPECTED EXCEPTION M3U8Process=')) + self.terminate() + # wait for m3u8_q to finish so it can cleanup ffmpeg + self.m3u8_q.join() + TERMINATE_REQUESTED = True + self.logger.debug('M3U8Process terminated {}'.format(os.getpid())) + + def sleep(self, _time): + global TERMINATE_REQUESTED + start_ttw = time.time() + for i in range(round(_time * 5)): + if not TERMINATE_REQUESTED: + time.sleep(self.duration * 0.2) + delta_ttw = time.time() - start_ttw + if delta_ttw > _time: + break + + def terminate(self): + global STREAM_QUEUE + try: + STREAM_QUEUE.put({'uri_dt': 'terminate'}) + time.sleep(0.01) + except ValueError as ex: + pass + + def get_stream_uri(self): + return self.plugins.plugins[self.channel_dict['namespace']] \ + .plugin_obj.get_channel_uri_ext(self.channel_dict['uid'], self.channel_dict['instance']) + + @handle_url_except() + def get_m3u8_data(self, _uri, _retries): + # it sticks here. Need to find a work around for the socket.timeout per process + global MAINTAIN_HTTP_SESSION + if not MAINTAIN_HTTP_SESSION: + M3U8Queue.http_session.close() + time.sleep(0.01) + M3U8Queue.http_session = requests.session() + return m3u8.load(_uri, headers=self.header, http_session=M3U8Queue.http_session) + + def segment_date_time(self, _segment): + if _segment: + return None + if _segment.current_program_date_time: + return None + return _segment.current_program_date_time.replace(microsecond=0) + + def add_to_stream_queue(self, _playlist): + global PLAY_LIST + global STREAM_QUEUE + global TERMINATE_REQUESTED + total_added = 0 + if _playlist.keys != [None]: + keys = [{"uri": key.absolute_uri, "method": key.method, "iv": key.iv} + for key in _playlist.keys if key] + if len(keys) != len(_playlist.segments): + keys = [{"uri": keys[0]['uri'], "method": keys[0]['method'], "iv": keys[0]['iv']} + for i in range(0, len(_playlist.segments))] + else: + keys = [None for i in range(0, len(_playlist.segments))] + num_segments = len(_playlist.segments) + if self.is_starting and not self.config[self.config_section]['player-play_all_segments']: + seg_to_play = self.config[self.config_section]['player-segments_to_play'] + if _playlist.playlist_type == 'vod': + seg_to_play = num_segments + elif seg_to_play > num_segments: + seg_to_play = num_segments + + skipped_seg = num_segments - seg_to_play + # total_added += self.add_segment(_playlist.segments[0], keys[0]) + + for m3u8_segment, key in zip(_playlist.segments[0:skipped_seg], keys[0:skipped_seg]): + total_added += self.add_segment(m3u8_segment, key, _default_played=True) + for i in range(skipped_seg, num_segments): + total_added += self.add_segment( + _playlist.segments[i], keys[i]) + self.is_starting = False + else: + key_list = list(PLAY_LIST.keys()) + if len(key_list) == 0: + i = 0 + else: + last_key = list(PLAY_LIST.keys())[-1] + i = 0 + for index, segment in enumerate(reversed(_playlist.segments)): + if self.use_full_duplicate_checking: + uri = segment.absolute_uri + elif self.use_pathonly_checking: + uri = urllib.parse.urlparse(segment.absolute_uri).path + else: + uri = segment.get_path_from_uri() + dt = self.segment_date_time(segment) + if self.use_date_on_key: + uri_dt = (uri, dt) + else: + uri_dt = (uri, 0) + if last_key == uri_dt: + i = num_segments - index + for m3u8_segment, key in zip( + _playlist.segments[i:num_segments], keys[i:num_segments]): + added = self.add_segment(m3u8_segment, key) + total_added += added + if added == 0 or TERMINATE_REQUESTED: + break + time.sleep(0.1) + return total_added + + def add_segment(self, _segment, _key, _default_played=False): + global TERMINATE_REQUESTED + self.set_cue_status(_segment) + if self.use_full_duplicate_checking: + uri = _segment.absolute_uri + elif self.use_pathonly_checking: + uri = urllib.parse.urlparse(_segment.absolute_uri).path + else: + uri = _segment.get_path_from_uri() + + + + uri_full = _segment.absolute_uri + dt = self.segment_date_time(_segment) + if self.use_date_on_key: + uri_dt = (uri, dt) + else: + uri_dt = (uri, 0) + if uri_dt not in PLAY_LIST.keys(): + played = _default_played + filtered = False + cue_status = self.set_cue_status(_segment) + if self.file_filter is not None: + m = self.file_filter.match(urllib.parse.unquote(uri_full)) + if m: + filtered = True + PLAY_LIST[uri_dt] = { + 'uid': self.channel_dict['uid'], + 'uri': uri_full, + 'played': played, + 'filtered': filtered, + 'duration': _segment.duration, + 'cue': cue_status, + 'key': _key + } + if _segment.duration > 0: + # use geometric averaging of 4 items + self.duration = (self.duration*3 + _segment.duration)/4 + try: + if not played and not TERMINATE_REQUESTED: + self.logger.debug('Added {} to play queue {}' + .format(uri_full, os.getpid())) + STREAM_QUEUE.put({'uri_dt': uri_dt, + 'data': PLAY_LIST[uri_dt]}) + return 1 + if _default_played: + self.logger.debug('Skipping {} {} {}' + .format(uri_full, os.getpid(), _segment.program_date_time)) + except ValueError as ex: + # queue is closed, terminating + pass + else: + self.logger.warning('DUPLICATE FOUND {}'.format(uri_dt)) + + return 0 + + def remove_from_stream_queue(self, _playlist): + global PLAY_LIST + total_removed = 0 + if _playlist.discontinuity_sequence is not None: + disc_index = 0 + total_index = len(_playlist.segments) + url_list = [key[0] for key in PLAY_LIST] + for i, segment in enumerate(reversed(_playlist.segments)): + if segment.discontinuity: + disc_index = total_index - i + break + for segment in _playlist.segments[disc_index:total_index]: + if self.use_full_duplicate_checking: + s_uri = segment.absolute_uri + elif self.use_pathonly_checking: + s_uri = urllib.parse.urlparse(segment.absolute_uri).path + else: + s_uri = segment.get_path_from_uri() + s_dt = self.segment_date_time(segment) + if self.use_date_on_key: + s_key = (s_uri, s_dt) + else: + s_key = (s_uri, 0) + + if s_key in PLAY_LIST.keys(): + continue + else: + try: + i = url_list.index(s_uri) + PLAY_LIST = utils.rename_dict_key(list(PLAY_LIST.keys())[i], s_key, PLAY_LIST) + except ValueError as ex: + # not in list + pass + + for segment_key in list(PLAY_LIST.keys()): + is_found = False + for segment_m3u8 in _playlist.segments: + if self.use_full_duplicate_checking: + s_uri = segment_m3u8.absolute_uri + elif self.use_pathonly_checking: + s_uri = urllib.parse.urlparse(segment_m3u8.absolute_uri).path + else: + s_uri = segment_m3u8.get_path_from_uri() + s_dt = self.segment_date_time(segment_m3u8) + if self.use_date_on_key: + s_key = (s_uri, s_dt) + else: + s_key = (s_uri, 0) + if segment_key == s_key: + is_found = True + break + if not is_found: + if PLAY_LIST[segment_key]['played']: + del PLAY_LIST[segment_key] + total_removed += 1 + self.logger.debug('Removed {} from play queue {}' + .format(segment_key[0], os.getpid())) + continue + else: + break + return total_removed + + def set_cue_status(self, _segment): + if _segment.cue_out_start: + return 'out' + elif _segment.cue_in: + return 'in' + else: + return None + + +def clear_q(q): + try: + while True: + q.get_nowait() + except (Empty, ValueError, EOFError) as ex: + pass + + +def clear_queues(): + # closing a multiprocessing queue with 'close' without emptying + # it will prevent a process dependant on that queue + # from terminating and fulfilling a 'join' if there was an entry in the queue + # so we need to proactivley clear all queue entries instead of closing the queues + global STREAM_QUEUE + global OUT_QUEUE + global IN_QUEUE + clear_q(OUT_QUEUE) + clear_q(STREAM_QUEUE) + clear_q(IN_QUEUE) + +def out_queue_put(data_dict): + global OUT_QUEUE + logger = logging.getLogger(__name__) + for t in OUT_QUEUE_LIST: + data_dict['thread_id'] = t + OUT_QUEUE.put(data_dict) + time.sleep(0.01) + + +def start(_config, _plugins, _m3u8_queue, _data_queue, _channel_dict, extra=None): + """ + All items in this process must handle a socket timeout of 5.0 + """ + global IN_QUEUE + global STREAM_QUEUE + global OUT_QUEUE + global TERMINATE_REQUESTED + logger = None + try: + utils.logging_setup(_plugins.config_obj.data) + logger = logging.getLogger(__name__) + socket.setdefaulttimeout(5.0) + IN_QUEUE = _m3u8_queue + STREAM_QUEUE = Queue(maxsize=MAX_STREAM_QUEUE_SIZE) + OUT_QUEUE = _data_queue + p_m3u8 = M3U8Process(_config, _plugins, _channel_dict) + while not TERMINATE_REQUESTED: + try: + q_item = IN_QUEUE.get() + if q_item['uri'] == 'terminate': + OUT_QUEUE_LIST.remove(q_item['thread_id']) + if not len(OUT_QUEUE_LIST): + TERMINATE_REQUESTED = True + clear_queues() + else: + clear_q(OUT_QUEUE) + time.sleep(0.01) + + # clear queues in case queues are full (eg VOD) with queue.put stmts blocked + # p_m3u8 & m3u8_q then see TERMINATE_REQUESTED and exit including stopping ffmpeg + OUT_QUEUE.put({ + 'thread_id': q_item['thread_id'], + 'uri': 'terminate', + 'data': None, + 'stream': None, + 'atsc': None}) + time.sleep(0.01) + if not len(OUT_QUEUE_LIST): + p_m3u8.join() + elif q_item['uri'] == 'status': + if q_item['thread_id'] not in OUT_QUEUE_LIST: + OUT_QUEUE_LIST.append(q_item['thread_id']) + logger.debug('Adding client thread {} to m3u8 queue list'.format(q_item['thread_id'])) + STREAM_QUEUE.put({'uri_dt': 'status'}) + logger.debug('Sending Status request to stream queue {}'.format(os.getpid())) + time.sleep(0.01) + elif q_item['uri'] == 'restart_http': + logger.debug('HTTP Session restarted {}'.format(os.getpid())) + temp_session = M3U8Queue.http_session + M3U8Queue.http_session = requests.session() + temp_session.close() + temp_session = None + time.sleep(0.01) + else: + logger.debug('UNKNOWN m3u8 queue request {}'.format(q_item['uri'])) + except (KeyboardInterrupt, EOFError, TypeError, ValueError) as ex: + TERMINATE_REQUESTED = True + try: + clear_queues() + out_queue_put({ + 'uri': 'terminate', + 'data': None, + 'stream': None, + 'atsc': None}) + time.sleep(0.01) + STREAM_QUEUE.put({'uri_dt': 'terminate'}) + time.sleep(0.1) + except (EOFError, TypeError, ValueError) as ex: + pass + logger.debug('4 m3u8_queue process terminated {}'.format(os.getpid())) + sys.exit() + clear_queues() + logger.debug('1 m3u8_queue process terminated {}'.format(os.getpid())) + sys.exit() + except Exception as ex: + logger.exception('{}'.format( + 'UNEXPECTED EXCEPTION startup')) + TERMINATE_REQUESTED = True + logger.debug('3 m3u8_queue process terminated {}'.format(os.getpid())) + sys.exit() + except KeyboardInterrupt as ex: + TERMINATE_REQUESTED = True + logger.debug('2 m3u8_queue process terminated {}'.format(os.getpid())) + sys.exit() diff --git a/lib/streams/m3u8_redirect.py b/lib/streams/m3u8_redirect.py new file mode 100644 index 0000000000000000000000000000000000000000..de9661a0f37d2b28b961bade58df2c16a2afbb99 --- /dev/null +++ b/lib/streams/m3u8_redirect.py @@ -0,0 +1,46 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +from lib.web.pages.templates import web_templates +from .stream import Stream + + +class M3U8Redirect(Stream): + + # There is no way to know the number of stream running on a redirect. + # They can stop anytime without notification, so tuner tracking is + # disabled + + def gen_m3u8_response(self, _channel_dict): + """ + Returns dict where the dict is consistent with + the method do_dict_response requires as an argument + """ + channel_uri = self.get_stream_uri(_channel_dict) + if not channel_uri: + self.logger.warning('Unknown channel:{}'.format(_channel_dict['uid'])) + return { + 'code': 501, + 'headers': {'Content-type': 'text/html'}, + 'text': web_templates['htmlError'].format('501 - Unknown channel')} + + self.logger.info('Sending M3U8 file directly to client') + return { + 'code': 302, + 'headers': {'Location': channel_uri}, + 'text': None} diff --git a/lib/streams/pts_resync.py b/lib/streams/pts_resync.py new file mode 100644 index 0000000000000000000000000000000000000000..6a8375b6fead9b2a8ed9e7c51d1f841a963c256b --- /dev/null +++ b/lib/streams/pts_resync.py @@ -0,0 +1,168 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import copy +import logging +import os +import subprocess +import time +from threading import Thread + +from .stream_queue import StreamQueue + + +class PTSResync: + + def __init__(self, _config, _config_section, _id): + self.logger = logging.getLogger(__name__) + self.config = _config + self.config_section = _config_section + self.empty_packet_count = 0 + self.is_restart_requested = False + self.is_looping = False + self.id = _id + self.ffmpeg_proc = None + if self.config[self.config_section]['player-enable_pts_resync']: + if self.config[self.config_section]['player-pts_resync_type'] == 'ffmpeg': + self.ffmpeg_proc = self.open_ffmpeg_proc() + self.stream_queue = StreamQueue(188, self.ffmpeg_proc, _id) + if self.config[self.config_section]['player-pts_resync_type'] == 'ffmpeg': + self.logger.debug('PTS Resync running ffmpeg') + + def video_to_stdin(self, _video): + video_copy = copy.copy(_video.data) + i = 3 + self.is_looping = False + while i > 0: + i -= 1 + try: + if video_copy: + self.ffmpeg_proc.stdin.write(video_copy) + break + except (BrokenPipeError, TypeError) as ex: + # This occurs when the process does not start correctly + self.logger.debug('BROKENPIPE {} {}'.format(self.ffmpeg_proc.pid, str(ex))) + if not self.is_restart_requested: + errcode = self.restart_ffmpeg() + self.is_looping = True + else: + time.sleep(0.7) + + except ValueError: + # during termination, writing to a closed port, ignore + break + self.is_looping = False + video_copy = None + + def restart_ffmpeg(self): + self.logger.debug('Restarting PTSResync ffmpeg due to no ffmpeg processing {}'.format(self.ffmpeg_proc.pid)) + errcode = 0 + self.empty_packet_count = 0 + self.stream_queue.terminate() + while True: + try: + self.ffmpeg_proc.terminate() + #self.ffmpeg_proc.wait(timeout=1.5) + break + except ValueError: + pass + except subprocess.TimeoutExpired: + time.sleep(0.01) + try: + sout, serr = self.ffmpeg_proc.communicate() + errcode = self.ffmpeg_proc.returncode + # an errcode of 1 means ffmpeg could not run + if errcode == 1: + self.logger.debug('FFMPEG ERRCODE: {}, unable for pts_resync to process segment in ffmpeg'.format(self.ffmpeg_proc.returncode)) + except ValueError: + pass + while self.ffmpeg_proc.poll() is None: + time.sleep(0.1) + self.ffmpeg_proc = self.open_ffmpeg_proc() + self.stream_queue = StreamQueue(188, self.ffmpeg_proc, self.id) + time.sleep(0.5) + return errcode + + + def resequence_pts(self, _video): + if not self.config[self.config_section]['player-enable_pts_resync']: + return + if _video.data is None: + return + if self.config[self.config_section]['player-pts_resync_type'] == 'ffmpeg': + while self.is_looping: + time.sleep(0.5) + t_in = Thread(target=self.video_to_stdin, args=(_video,)) + t_in.start() + time.sleep(0.1) + new_video = self.stream_queue.read() + if not new_video: + self.empty_packet_count += 1 + if self.empty_packet_count > 2: + if not self.is_restart_requested: + self.is_restart_requested = True + self.restart_ffmpeg() + self.is_restart_requested = False + else: + self.empty_packet_count = 0 + + _video.data = new_video + elif self.config[self.config_section]['player-pts_resync_type'] == 'internal': + self.logger.warning('player-pts_resync_type internal NOT IMPLEMENTED') + else: + self.logger.error('player-pts_resync_type UNKNOWN TYPE {}'.format( + self.config[self.config_section]['player-pts_resync_type'])) + + def terminate(self): + if self.ffmpeg_proc is not None: + self.stream_queue.terminate() + self.ffmpeg_proc.stdin.flush() + self.ffmpeg_proc.stdout.flush() + self.ffmpeg_proc.terminate() + try: + sout, serr = self.ffmpeg_proc.communicate() + errcode = self.ffmpeg_proc.returncode + if errcode == 1: + self.logger.debug('FFMPEG errcode on exit: {}, unable for pts_resync to process segment in ffmpeg'.format(self.ffmpeg_proc.returncode)) + except ValueError: + pass + + def open_ffmpeg_proc(self): + """ + ffmpeg drops the first 9 frame/video packets when the program starts. + this means everytime a refresh occurs, 9 frames will be dropped. This is + visible by looking at the video packets for a 6 second window being 171 + instead of 180. Following the first read, the packets increase to 180. + """ + ffmpeg_command = [ + self.config['paths']['ffmpeg_path'], + '-nostats', + '-hide_banner', + '-loglevel', 'fatal', + '-i', 'pipe:0', + '-fflags', '+flush_packets+genpts', + '-avioflags', '+direct', + '-f', 'mpegts', + '-c', 'copy', + 'pipe:1'] + ffmpeg_process = subprocess.Popen( + ffmpeg_command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + bufsize=-1) + return ffmpeg_process diff --git a/lib/streams/pts_validation.py b/lib/streams/pts_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..b9c966fa547b1ab72922a1243fe5968167a902dc --- /dev/null +++ b/lib/streams/pts_validation.py @@ -0,0 +1,233 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import json +import logging +import subprocess + +import lib.common.utils as utils + + +class PTSValidation: + logger = None + + def __init__(self, _config, _channel_dict): + self.ffmpeg_proc = None + self.last_refresh = None + self.buffer_prev_time = None + self.small_pkt_streaming = False + self.block_max_pts = 0 + self.block_prev_pts = 0 + self.prev_last_pts = 0 + self.default_duration = 0 + self.block_moving_avg = 0 + self.channel_dict = _channel_dict + self.write_buffer = None + self.stream_queue = None + self.config = _config + self.pts_json = None + if PTSValidation.logger is None: + PTSValidation.logger = logging.getLogger(__name__) + self.config_section = utils.instance_config_section( + self.channel_dict['namespace'], self.channel_dict['instance']) + + def check_pts(self, _video): + """ + Checks the PTS in the video stream. If a bad PTS packet is found, + it will update the video stream until the stream is valid. + returns a dict containing 3 values + byteoffset (if >0, then write the offset before continuing) + refresh_stream (if True, then refresh the stream) + reread_buffer (if True, then drop current video.data and re-read buffer) + The items should be processed in the order listed above + """ + self.pts_json = self.get_probe_results(_video) + if self.pts_json is None: + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': False} + pkt_len = self.check_for_video_pkts() + if pkt_len < 1: + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': False} + pts_data = self.get_pts_values(self.pts_json) + if pts_data is None: + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': False} + + pts_minimum = int(self.config[self.config_section]['player-pts_minimum']) + if pts_data['first_pts'] < pts_minimum: + if pts_data['last_pts'] < pts_minimum: + self.logger.debug('Small PTS for entire stream, drop and refresh buffer') + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': True} + elif pts_data['last_pts'] <= self.prev_last_pts: + self.logger.debug('Small PTS to Large PTS with entire PTS in the past. last_pts={} vs prev={}' + .format(pts_data['last_pts'], self.prev_last_pts)) + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': True} + else: + byte_offset = self.find_bad_pkt_offset(from_front=False) + if byte_offset > 0: + self.logger.debug('{} {}{}'.format( + 'Small bad PTS on front with good large PTS on end.', + 'Writing good bytes=', byte_offset)) + return {'refresh_stream': False, 'byteoffset': -byte_offset, 'reread_buffer': True} + else: + self.logger.debug('RARE CASE: Large delta but no bad PTS ... unknown case, ignore') + self.prev_last_pts = pts_data['last_pts'] + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': False} + elif pts_data['last_pts'] < pts_minimum: + self.logger.debug('RARE CASE: Large PTS on front with small PTS on end.') + return {'refresh_stream': True, 'byteoffset': 0, 'reread_buffer': False} + elif pts_data['delta_from_prev'] > \ + int(self.config[self.config_section]['player-pts_max_delta']): + self.logger.debug('{} {}{}'.format( + 'Large delta PTS between reads. Refreshing Stream', + 'DELTA=', pts_data['delta_from_prev'])) + return {'refresh_stream': True, 'byteoffset': 0, 'reread_buffer': False} + elif pts_data['pts_size'] > \ + int(self.config[self.config_section]['player-pts_max_delta']): + byte_offset = self.find_bad_pkt_offset(from_front=True) + if byte_offset > 0: + self.logger.debug('{} {}{}'.format( + 'Large delta PTS with good front.', + 'Writing good bytes=', byte_offset)) + return {'refresh_stream': True, 'byteoffset': byte_offset, 'reread_buffer': False} + else: + self.logger.debug('RARE CASE: Large delta but no bad PTS ... unknown case, ignore') + self.prev_last_pts = pts_data['last_pts'] + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': False} + + elif pts_data['first_pts'] < self.prev_last_pts: + if pts_data['last_pts'] <= self.prev_last_pts: + self.logger.debug('Entire PTS buffer in the past last_pts={} vs prev={}'.format(pts_data['last_pts'], + self.prev_last_pts)) + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': True} + else: + byte_offset = self.find_past_pkt_offset(self.prev_last_pts) + self.logger.debug('{} {}{} {}'.format( + 'PTS buffer in the past.', + ' Writing end bytes from offset=', byte_offset, + 'out to client')) + if byte_offset < 0: + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': True} + else: + self.prev_last_pts = pts_data['last_pts'] + return {'refresh_stream': False, 'byteoffset': -byte_offset, 'reread_buffer': True} + else: + self.prev_last_pts = pts_data['last_pts'] + return {'refresh_stream': False, 'byteoffset': 0, 'reread_buffer': False} + + def check_for_video_pkts(self): + try: + pkt_len = len(self.pts_json['packets']) + except KeyError: + pkt_len = 0 + self.logger.debug('Packet received with no video packet included') + return pkt_len + + def get_pts_values(self, _pts_json): + try: + first_pts = _pts_json['packets'][0]['pts'] + if self.prev_last_pts == 0: + delta_from_prev = 0 + else: + delta_from_prev = first_pts - self.prev_last_pts + end_of_json = len(self.pts_json['packets']) - 1 + if 'duration' in self.pts_json['packets'][end_of_json]: + dur = self.pts_json['packets'][end_of_json]['duration'] + self.default_duration = dur + else: + dur = self.default_duration + last_pts = self.pts_json['packets'][end_of_json]['pts'] + dur + except KeyError: + self.logger.info('KeyError exception: no pts in first or last packet, ignore') + return None + pts_size = abs(last_pts - first_pts) + self.logger.debug('{}{} {}{} {}{} {}{} {}{}'.format( + 'First PTS=', first_pts, + 'Last PTS=', last_pts, + 'PTS SIZE=', pts_size, + 'DELTA PTS=', delta_from_prev, + 'Pkts Rcvd=', len(_pts_json['packets']))) + return {'first_pts': first_pts, 'last_pts': last_pts, + 'pts_size': pts_size, 'delta_from_prev': delta_from_prev} + + def find_bad_pkt_offset(self, from_front): + """ + Determine where in the stream the pts diverges + """ + num_of_pkts = len(self.pts_json['packets']) - 1 # index from 0 to len - 1 + i = 1 + prev_pkt_pts = self.pts_json['packets'][0]['pts'] + byte_offset = -1 + size = 0 + while i < num_of_pkts: + next_pkt_pts = self.pts_json['packets'][i]['pts'] + + if size == 0 and 'size' in self.pts_json['packets'][i]: + size = int(self.pts_json['packets'][i]['size']) + if abs(next_pkt_pts - prev_pkt_pts) \ + > int(self.config[self.config_section]['player-pts_max_delta']): + # found place where bad packets start + # only video codecs have byte position info + if from_front: + pts = prev_pkt_pts + byte_offset = int((int(self.pts_json['packets'][i - 1]['pos']) + size) / 188) * 188 + self.prev_last_pts = pts + else: + pts = next_pkt_pts + byte_offset = int((int(self.pts_json['packets'][i]['pos']) - 1) / 188) * 188 + self.prev_last_pts = self.pts_json['packets'][num_of_pkts]['pts'] + self.logger.debug('Middle PTS {} byte_offset={}'.format(pts, byte_offset)) + break + + i += 1 + prev_pkt_pts = next_pkt_pts + return byte_offset + + def find_past_pkt_offset(self, prev_last_pts): + num_of_pkts = len(self.pts_json['packets']) - 1 # index from 0 to len - 1 + next_pkt_pts = 0 + i = 0 + byte_offset = -1 + while i < num_of_pkts: + prev_pkt_dts = next_pkt_pts + next_pkt_pts = self.pts_json['packets'][i]['pts'] + if next_pkt_pts >= prev_last_pts - 2: + # found place where future packets start + # only video codecs have byte position info + byte_offset = int(int(self.pts_json['packets'][i]['pos']) / 188) * 188 + self.logger.debug( + '{}{} {}{} {}{}'.format('Future PTS at byte_offset=', byte_offset, 'pkt_pts=', next_pkt_pts, + 'prev_pkt=', prev_pkt_dts)) + break + i += 1 + return byte_offset + + def get_probe_results(self, _video): + ffprobe_command = [self.config['paths']['ffprobe_path'], + '-print_format', 'json', + '-v', 'quiet', '-show_packets', + '-select_streams', 'v:0', + '-show_entries', 'side_data=:packet=pts,pos,duration,size', + '-'] + cmdpts = subprocess.Popen(ffprobe_command, + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + ptsout = cmdpts.communicate(_video.data)[0] + exit_code = cmdpts.wait() + if exit_code != 0: + self.logger.warning('FFPROBE failed to execute with error code: {}' + .format(exit_code)) + return None + return json.loads(ptsout) diff --git a/lib/streams/stream.py b/lib/streams/stream.py new file mode 100644 index 0000000000000000000000000000000000000000..cdfd829d109469aa7c0b618416110c7716f228da --- /dev/null +++ b/lib/streams/stream.py @@ -0,0 +1,118 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging + +from lib.web.pages.templates import web_templates +from lib.clients.web_handler import WebHTTPHandler +import lib.common.utils as utils + + +class Stream: + logger = None + + def __init__(self, _plugins, _hdhr_queue): + self.plugins = _plugins + self.namespace = '' + self.instance = '' + self.config = self.plugins.config_obj.data + self.hdhr_queue = _hdhr_queue + if Stream.logger is None: + Stream.logger = logging.getLogger(__name__) + + def put_hdhr_queue(self, _namespace, _index, _channel, _status): + if not self.config['hdhomerun']['disable_hdhr']: + self.hdhr_queue.put( + {'namespace': _namespace, 'tuner': _index, + 'channel': _channel, 'status': _status}) + + def find_tuner(self, _namespace, _instance, _ch_num, _isvod): + # keep track of how many tuners we can use at a time + found = -1 + scan_list = WebHTTPHandler.rmg_station_scans[_namespace] + for index, scan_status in enumerate(scan_list): + # the first idle tuner gets it + if scan_status == 'Idle' and found == -1: + found = index + elif isinstance(scan_status, dict): + if scan_status['instance'] == _instance \ + and scan_status['ch'] == _ch_num \ + and not _isvod \ + and scan_status['mux'] \ + and not scan_status['mux'].terminate_requested: + found = index + break + if found == -1: + return found + if WebHTTPHandler.rmg_station_scans[_namespace][index] != 'Idle': + self.logger.debug('Reusing tuner {} {}:{} ch:{}'.format(found, _namespace, _instance, _ch_num)) + else: + self.logger.debug('Adding new tuner {} for stream {}:{} ch:{}'.format(found, _namespace, _instance, _ch_num)) + WebHTTPHandler.rmg_station_scans[_namespace][found] = { \ + 'instance': _instance, + 'ch': _ch_num, + 'mux': None, + 'status': 'Starting'} + self.put_hdhr_queue(_namespace, index, _ch_num, 'Stream') + return found + + def set_service_name(self, _channel_dict): + updated_chnum = utils.wrap_chnum( + str(_channel_dict['display_number']), _channel_dict['namespace'], + _channel_dict['instance'], self.config) + + if self.config['epg']['epg_channel_number']: + service_name = updated_chnum + \ + ' ' + _channel_dict['display_name'] + else: + service_name = _channel_dict['display_name'] + return service_name + + def get_stream_uri(self, _channel_dict): + return self.plugins.plugins[_channel_dict['namespace']] \ + .plugin_obj.get_channel_uri_ext(_channel_dict['uid'], _channel_dict['instance']) + + def gen_response(self, _namespace, _instance, _ch_num, _isvod): + """ + Returns dict where the dict is consistent with + the method do_dict_response requires as an argument + A code other than 200 means do not tune + dict also include a "tuner_index" that informs caller what tuner is allocated + """ + self.namespace = _namespace + self.instance = _instance + i = self.find_tuner(_namespace, _instance, _ch_num, _isvod) + if i >= 0: + return { + 'tuner': i, + 'code': 200, + 'headers': {'Content-type': 'video/MP2T;'}, + 'text': None} + else: + self.logger.warning( + 'All tuners already in use [{}][{}] max tuners: {}' + .format(_namespace, _instance, len(WebHTTPHandler.rmg_station_scans[_namespace]))) + return { + 'tuner': i, + 'code': 400, + 'headers': {'Content-type': 'text/html'}, + 'text': web_templates['htmlError'].format('400 - All tuners already in use.')} + + @property + def config_section(self): + return utils.instance_config_section(self.namespace, self.instance) diff --git a/lib/streams/stream_queue.py b/lib/streams/stream_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..24920ab66a32246e38023052ebd3e5df278f17bb --- /dev/null +++ b/lib/streams/stream_queue.py @@ -0,0 +1,79 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import time +from threading import Thread + + +class StreamQueue: + """ + This works when we run a process that has an output of a continuous stream. + Used with ffmpeg and streamlink + """ + + def __init__(self, _bytes_per_read, _proc, _stream_id): + self.logger = logging.getLogger(__name__) + self.bytes_per_read = _bytes_per_read + self.sout = _proc.stdout + self.serr = _proc.stderr + self.queue = [] + self.proc = _proc + self.stream_id = _stream_id + self.is_terminated = False + + def _populate_queue(): + """ + Collect lines from 'stream' and put them in 'queue'. + """ + while not self.is_terminated: + try: + self.sout.flush() + video_data = self.sout.read(self.bytes_per_read) + if video_data: + self.queue.append(video_data) + else: + self.logger.debug('Stream ended for this process, exiting queue thread') + self.is_terminated = True + break + except ValueError: + # occurs on termination with buffer must not be NULL + self.is_terminated = True + break + self._t = Thread(target=_populate_queue, args=()) + self._t.daemon = True + self._t.start() # start collecting blocks from the stream + + def read(self): + is_queue_changing = True + queue_size = len(self.queue) + while is_queue_changing: + time.sleep(0.1) + if len(self.queue) != queue_size: + queue_size = len(self.queue) + else: + is_queue_changing = False + + if len(self.queue) > 0: + clone_queue = self.queue.copy() + del self.queue[:len(clone_queue)] + return b''.join(clone_queue) + return None + + def terminate(self): + self.is_terminated = True diff --git a/lib/streams/streamlink_proxy.py b/lib/streams/streamlink_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..430369bf8cf78084245083a4fb7b833d4d91fb3c --- /dev/null +++ b/lib/streams/streamlink_proxy.py @@ -0,0 +1,256 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import errno +import subprocess +import time + +import lib.common.exceptions as exceptions +from lib.clients.web_handler import WebHTTPHandler +from lib.streams.video import Video +from lib.db.db_config_defn import DBConfigDefn +from .stream import Stream +from .stream_queue import StreamQueue +from .pts_validation import PTSValidation + +IDLE_TIMER = 20 # Duration for no video causing a refresh +MAX_IDLE_TIMER = 120 # duration for no video causing stream termination + +class StreamlinkProxy(Stream): + + def __init__(self, _plugins, _hdhr_queue): + self.streamlink_proc = None + self.last_refresh = None + self.block_prev_time = None + self.buffer_prev_time = None + self.small_pkt_streaming = False + self.block_max_pts = 0 + self.block_prev_pts = 0 + self.prev_last_pts = 0 + self.default_duration = 0 + self.block_moving_avg = 0 + self.channel_dict = None + self.write_buffer = None + self.stream_queue = None + self.pts_validation = None + self.tuner_no = -1 + super().__init__(_plugins, _hdhr_queue) + self.db_configdefn = DBConfigDefn(self.config) + self.video = Video(self.config) + + def update_tuner_status(self, _status): + ch_num = self.channel_dict['display_number'] + namespace = self.channel_dict['namespace'] + scan_list = WebHTTPHandler.rmg_station_scans[namespace] + tuner = scan_list[self.tuner_no] + if type(tuner) == dict and tuner['ch'] == ch_num: + WebHTTPHandler.rmg_station_scans[namespace][self.tuner_no]['status'] = _status + + def stream(self, _channel_dict, _write_buffer, _tuner_no): + global MAX_IDLE_TIMER + self.logger.info('Using streamlink_proxy for channel {}'.format(_channel_dict['uid'])) + self.tuner_no = _tuner_no + self.channel_dict = _channel_dict + self.write_buffer = _write_buffer + self.config = self.db_configdefn.get_config() + MAX_IDLE_TIMER = self.config[self.namespace.lower()]['stream-g_stream_timeout'] + + self.pts_validation = PTSValidation(self.config, self.channel_dict) + channel_uri = self.get_stream_uri(self.channel_dict) + if not channel_uri: + self.logger.warning('Unknown Channel {}'.format(_channel_dict['uid'])) + return + self.streamlink_proc = self.open_streamlink_proc(channel_uri) + if not self.streamlink_proc: + return + time.sleep(0.01) + self.last_refresh = time.time() + self.block_prev_time = self.last_refresh + self.buffer_prev_time = self.last_refresh + try: + self.read_buffer() + except exceptions.CabernetException as ex: + self.logger.info(str(ex)) + return + while True: + if not self.video.data: + self.logger.info( + '1 No Video Data, refreshing stream {} {}' + .format(_channel_dict['uid'], self.streamlink_proc.pid)) + self.streamlink_proc = self.refresh_stream() + else: + try: + self.validate_stream() + self.update_tuner_status('Streaming') + start_ttw = time.time() + self.write_buffer.write(self.video.data) + delta_ttw = time.time() - start_ttw + self.logger.info( + 'Serving {} {} ({}B) ttw:{:.2f}s' + .format(self.streamlink_proc.pid, _channel_dict['uid'], + len(self.video.data), delta_ttw)) + except IOError as e: + if e.errno in [errno.EPIPE, errno.ECONNABORTED, errno.ECONNRESET, errno.ECONNREFUSED]: + self.logger.info('1. Connection dropped by end device {}'.format(self.streamlink_proc.pid)) + break + else: + self.logger.error('{}{}'.format( + '1 UNEXPECTED EXCEPTION=', e)) + raise + try: + self.read_buffer() + except exceptions.CabernetException as ex: + self.logger.info('{} {}'.format(ex, self.streamlink_proc.pid)) + break + except Exception as e: + self.logger.error('{}{}'.format( + '2 UNEXPECTED EXCEPTION=', e)) + break + self.terminate_stream() + + def validate_stream(self): + if not self.config[self.config_section]['player-enable_pts_filter']: + return + + has_changed = True + while has_changed: + has_changed = False + results = self.pts_validation.check_pts(self.video) + if results['byteoffset'] != 0: + if results['byteoffset'] < 0: + self.write_buffer.write(self.video.data[-results['byteoffset']:len(self.video.data) - 1]) + else: + self.write_buffer.write(self.video.data[0:results['byteoffset']]) + has_changed = True + if results['refresh_stream']: + self.streamlink_proc = self.refresh_stream() + self.read_buffer() + has_changed = True + if results['reread_buffer']: + self.read_buffer() + has_changed = True + return + + def read_buffer(self): + global MAX_IDLE_TIMER + global IDLE_TIMER + data_found = False + self.video.data = None + idle_timer = MAX_IDLE_TIMER # time slice segments are less than 10 seconds + while not data_found: + self.video.data = self.stream_queue.read() + if self.video.data: + data_found = True + else: + if self.stream_queue.is_terminated: + raise exceptions.CabernetException('Streamlink Terminated, exiting stream {}'.format(self.streamlink_proc.pid)) + + time.sleep(1) + idle_timer -= 1 + if idle_timer % IDLE_TIMER == 0: + self.logger.info( + '2 No Video Data, refreshing stream {}' + .format(self.streamlink_proc.pid)) + self.streamlink_proc = self.refresh_stream() + + if idle_timer < 1: + idle_timer = MAX_IDLE_TIMER # time slice segments are less than 10 seconds + self.logger.info( + 'No Video Data, terminating stream {}' + .format(self.streamlink_proc.pid)) + time.sleep(15) + self.streamlink_proc = self.terminate_stream() + raise exceptions.CabernetException('Unable to get video stream, terminating') + elif int(MAX_IDLE_TIMER / 2) == idle_timer: + self.update_tuner_status('No Reply') + return + + def terminate_stream(self): + self.logger.debug('Terminating streamlink stream {}'.format(self.streamlink_proc.pid)) + while True: + try: + self.streamlink_proc.terminate() + self.streamlink_proc.wait(timeout=1.5) + break + except ValueError: + pass + except subprocess.TimeoutExpired: + time.sleep(0.01) + + def refresh_stream(self): + self.last_refresh = time.time() + channel_uri = self.get_stream_uri(self.channel_dict) + self.terminate_stream() + + self.logger.debug('{}{}'.format( + 'Refresh Stream channelUri=', channel_uri)) + streamlink_process = self.open_streamlink_proc(channel_uri) + # make sure the previous streamlink is terminated before exiting + self.buffer_prev_time = time.time() + return streamlink_process + + def open_streamlink_proc(self, _channel_uri): + """ + streamlink drops the first 9 frame/video packets when the program starts. + this means everytime a refresh occurs, 9 frames will be dropped. This is + visible by looking at the video packets for a 6 second window being 171 + instead of 180. Following the first read, the packets increase to 180. + """ + header = self.channel_dict['json'].get('Header') + str_array = [] + llevel = self.config['handler_loghandler']['level'] + if llevel == 'DEBUG': + sl_llevel = 'trace' + elif llevel == 'INFO': + sl_llevel = 'info' + elif llevel == 'NOTICE': + sl_llevel = 'warning' + elif llevel == 'WARNING': + sl_llevel = 'error' + else: + sl_llevel = 'none' + + if header: + for key, value in header.items(): + str_array.append('--http-header') + str_array.append(key + '=' + value) + if key == 'Referer': + self.logger.debug('Using HTTP Referer: {} Channel: {}'.format(value, self.channel_dict['uid'])) + uri = '{}'.format(_channel_uri) + streamlink_command = [ + self.config['paths']['streamlink_path'], + '--stdout', + '--loglevel', sl_llevel, + '--ffmpeg-fout', 'mpegts', + '--stream-segment-attempts', '2', + '--stream-segment-timeout', '5', + uri, + '720,best' + ] + streamlink_command.extend(str_array) + try: + streamlink_process = subprocess.Popen( + streamlink_command, + stdout=subprocess.PIPE, + bufsize=-1) + except: + self.logger.error('Streamlink Binary Not Found: {}'.format(self.config['paths']['streamlink_path'])) + return + self.stream_queue = StreamQueue(188, streamlink_process, self.channel_dict['uid']) + time.sleep(0.1) + return streamlink_process diff --git a/lib/streams/thread_queue.py b/lib/streams/thread_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..1bf2e7febfbcd55fe8ab4a895e75631abbd780f4 --- /dev/null +++ b/lib/streams/thread_queue.py @@ -0,0 +1,200 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import logging +import threading +import time +from queue import Empty + +from multiprocessing import Queue, Process +from threading import Thread + + +class ThreadQueue(Thread): + """ + Takes a queue containing thread ids and pushes them + into other queues associated with those threads + Assumes queue item is a dict containing a name/value of "thread_id" + 'terminate' can be sent via name 'uri' to terminate a specific thread id + """ + # list of [threadid, queue] items + + def __init__(self, _queue, _config): + Thread.__init__(self) + self.logger = logging.getLogger(__name__ + str(threading.get_ident())) + # incoming queue containing the thread id of which outgoing queue to send it to. + self.queue = _queue + # outgoing queues + self.queue_list = {} + self.config = _config + self.terminate_requested = False + # The process using the incoming queue to send data + self._remote_proc = None + # incoming queue to the process, stored locally + self._status_queue = None + self.start() + + def __str__(self): + """ + Used to display the number of queues in the outgoing queue list + """ + return str(len(self.queue_list)) + + def run(self): + thread_id = None + try: + while not self.terminate_requested: + queue_item = self.queue.get() + thread_id = queue_item.get('thread_id') + if not thread_id: + self.logger.warning('Badly formatted queue. thread_id required and missing thread_id:{} uri:{}' + .format(queue_item.get('thread_id'), queue_item.get('uri'))) + continue + if not queue_item.get('uri'): + self.logger.warning('Badly formatted queue. uri required and missing thread_id:{} uri:{}' + .format(queue_item.get('thread_id'), queue_item.get('uri'))) + continue + if queue_item.get('uri') == 'terminate': + time.sleep(self.config['stream']['switch_channel_timeout']) + self.del_thread(thread_id, True) + out_queue = self.queue_list.get(thread_id) + if out_queue: + # Define the length of sleep to keep the queues from becoming full + # or using all the memory. Occurs with VOD streams. + # sleep timer auto-adjusts to keep the queue a little over 10 items + # in the outgoing queue + if out_queue.qsize() > 10: + s = out_queue.qsize()/2 + else: + s = 0.0 + out_queue.put(queue_item) + self.sleep(s) + + except (KeyboardInterrupt, EOFError) as ex: + self.terminate_requested = True + self.clear_queues() + self.logger.exception('{}{}'.format( + 'UNEXPECTED EXCEPTION ThreadQueue=', ex)) + except Exception as ex: + # tell everyone we are terminating badly + self.logger.exception('{}'.format( + 'UNEXPECTED EXCEPTION ThreadQueue')) + for qdict in self.queue_list.items(): + qdict[1].put({'thread_id': qdict[0], 'uri': 'terminate'}) + self.terminate_requested = True + self.clear_queues() + time.sleep(0.01) + + self.clear_queues() + self.terminate_requested = True + self.logger.debug('ThreadQueue terminated') + + def clear_queues(self): + self.clear_q(self.queue) + + def clear_q(self, _q): + try: + while True: + item = _q.get_nowait() + except (Empty, ValueError, EOFError, OSError) as ex: + pass + + def add_thread(self, _thread_id, _queue): + """ + Adds the thread id to the list of queues this class is sending data + """ + out_queue = self.queue_list.get(_thread_id) + self.queue_list[_thread_id] = _queue + if not out_queue: + self.logger.debug('Adding thread id queue to thread queue: {}'.format(_thread_id)) + + def del_thread(self, _thread_id, _is_inrun=False): + """ + Removes the thread id from the list of queues this class is sending data to + if queue list is empty, then will also set the terminate to True + and return True + _is_inrun is set to true when the call comes from the thread run method, + so wait for terminate is not required since it already is not waiting for get queue processing + """ + out_queue = self.queue_list.get(_thread_id) + if out_queue: + del self.queue_list[_thread_id] + self.logger.debug('Removing thread id queue from thread queue: {}'.format(_thread_id)) + if not len(self.queue_list): + # sleep to deal with boomerang effects on termination + # when the channel does a quick reset by the client + time.sleep(1.0) + if not len(self.queue_list): + self.logger.debug('Terminating thread queue') + self.terminate_requested = True + time.sleep(0.01) + self.clear_queues() + if _is_inrun: + return True + else: + self.queue.put({'thread_id': _thread_id, 'uri': 'terminate'}) + time.sleep(0.01) + self.wait_for_termination() + return True + else: + return False + else: + return True + + def wait_for_termination(self): + count = 50 + while self.is_alive() and count > 0: + time.sleep(0.1) + count -= 1 + self.clear_queues() + + def sleep(self, _time): + """ + Creates a sleep function that will exit quickly if the termination flag is set + """ + start_ttw = time.time() + for i in range(round(_time * 5)): + if not self.terminate_requested: + time.sleep(_time * 0.2) + else: + break + delta_ttw = time.time() - start_ttw + if delta_ttw > _time: + break + + @property + def remote_proc(self): + """ + process using the status_queue and sending to the incoming queue + """ + return self._remote_proc + + @remote_proc.setter + def remote_proc(self, _proc): + self._remote_proc = _proc + + @property + def status_queue(self): + """ + queue used by the remote process as its incoming queue + """ + return self._status_queue + + @status_queue.setter + def status_queue(self, _queue): + self._status_queue = _queue diff --git a/lib/streams/video.py b/lib/streams/video.py new file mode 100644 index 0000000000000000000000000000000000000000..574c12393cafb6d496ea2b0c6623ee7663bdf733 --- /dev/null +++ b/lib/streams/video.py @@ -0,0 +1,29 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import subprocess +import time + +from lib.common.string_obj import StringObj + +class Video(StringObj): + + def __init__(self, _config): + super().__init__() + self.config = _config + diff --git a/lib/tvheadend/__init__.py b/lib/tvheadend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/tvheadend/config_example.ini b/lib/tvheadend/config_example.ini new file mode 100644 index 0000000000000000000000000000000000000000..15c4ecbc2096a723438a98fd467b9593966fe50b --- /dev/null +++ b/lib/tvheadend/config_example.ini @@ -0,0 +1,6 @@ +# Add any plugin by creating config items below +# [pluginname_instancename] +# label = Any name you want. Displays in the web page settings area +#ex: +[plutotv_default] +label = PlutoTV Instance diff --git a/lib/tvheadend/epg_category.py b/lib/tvheadend/epg_category.py new file mode 100644 index 0000000000000000000000000000000000000000..4343ed2cc28738bd40363a3d14882a9e3240fc1f --- /dev/null +++ b/lib/tvheadend/epg_category.py @@ -0,0 +1,230 @@ +# pylama:ignore=E203,E221 +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +# KODI COLOR MAPPINGS +# 0 Other/Unknown Grey +# 16 Movie Orange +# 32 News Light Green +# 48 TV Show Yellow +# 64 Sports Red +# 80 Child Cyan +# 96 Music Green +# 112 Arts Blue +# 128 Social Light Grey +# 144 Science Purple +# 160 Hobby Light Purple +# 176 Special Light Blue +# 192 Other/Unknown Grey +# 208 Other/Unknown Grey +# 224 Other/Unknown Grey +# 240 Other/Unknown Grey + +groups = { + 'DRAMA': 'Drama', + 'COMEDY': 'Comedy', + 'CLASSICS': 'Classics', + 'DIY': 'DIY', + 'DOCUMENTARIES': 'Documentaries', + 'ENTERTAINMENT': 'Entertainment', + 'GAMING': 'Gaming', + 'HOLIDAY': 'Holiday', + 'INTERNATIONAL': 'International', + 'KIDS': 'Kids', + 'LIFESTYLE': 'Lifestyle', + 'LOCAL': 'Local', + 'MOVIES': 'Movies', + 'MUSIC': 'Music', + 'MYSTERY': 'Mystery', + 'NEW': 'New', + 'NEWS': 'News', + 'REALITY': 'Reality', + 'RELIGION': 'Religion', + 'SCIENCE': 'Science', + 'SPANISH': 'Spanish', + 'SPORTS': 'Sports', + 'TRAVEL': 'Travel', + 'WESTERNS': 'Westerns' + } + +# TVHEADEND CATEGORIES +tvh_genres = { + 'MOVIE': 'Movie / Drama', + 'THRILLER': 'Detective / Thriller', + 'ADVENTURE': 'Adventure / Western / War', + 'SF': 'Science fiction / Fantasy / Horror', + 'COMEDY': 'Comedy', + 'SOAP': 'Soap / Melodrama / Folkloric', + 'ROMANCE': 'Romance', + 'HISTORICAL': 'Serious / Classical / Religious / Historical movie / Drama', + 'XXX': 'Adult movie / Drama', + + 'NEWS': 'News / Current affairs', + 'WEATHER': 'News / Weather report', + 'NEWS_MAGAZINE': 'News magazine', + 'DOCUMENTARY': 'Documentary', + 'DEBATE': 'Discussion / Interview / Debate', + 'INTERVIEW': 'Discussion / Interview / Debate', + + 'SHOW': 'Show / Game show', + 'GAME': 'Game show / Quiz / Contest', + 'VARIETY': 'Variety show', + 'TALK_SHOW': 'Talk show', + + 'SPORT': 'Sports', + 'SPORT_SPECIAL': 'Special events (Olympic Games; World Cup; etc.)', + 'SPORT_MAGAZINE': 'Sports magazines', + 'FOOTBALL': 'Football / Soccer', + 'TENNIS': 'Tennis / Squash', + 'SPORT_TEAM': 'Team sports (excluding football)', + 'ATHLETICS': 'Athletics', + 'SPORT_MOTOR': 'Motor sport', + 'SPORT_WATER': 'Water sport', + 'SPORT_WINTER': 'Winter sports', + 'SPORT_HORSES': 'Equestrian', + 'MARTIAL_ARTS': 'Martial sports', + + 'KIDS': "Children's / Youth programs", + 'KIDS_0_5': "Pre-school children's programs", + 'KIDS_6_14': 'Entertainment programs for 6 to 14', + 'KIDS_10_16': 'Entertainment programs for 10 to 16', + 'EDUCATIONAL': 'Informational / Educational / School programs', + 'CARTOON': 'Cartoons / Puppets', + + 'MUSIC': 'Music / Ballet / Dance', + 'ROCK_POP': 'Rock / Pop', + 'CLASSICAL': 'Serious music / Classical music', + 'FOLK': 'Folk / Traditional music', + 'JAZZ': 'Jazz', + 'OPERA': 'Musical / Opera', + 'BALLET': 'Ballet', + + 'CULTURE': 'Arts / Culture (without music)', + 'PERFORMING': 'Performing arts', + 'FINE_ARTS': 'Fine arts', + 'RELIGION': 'Religion', + 'POPULAR_ART': 'Popular culture / Traditional arts', + 'LITERATURE': 'Literature', + 'FILM': 'Film / Cinema', + 'EXPERIMENTAL_FILM': 'Experimental film / Video', + 'BROADCASTING': 'Broadcasting / Press', + 'NEW_MEDIA': 'New media', + 'ARTS_MAGAZINE': 'Arts magazines / Culture magazines', + 'FASHION': 'Fashion', + + 'SOCIAL': 'Social / Political issues / Economics', + 'MAGAZINE': 'Magazines / Reports / Documentary', + 'ECONOMIC': 'Economics / Social advisory', + 'VIP': 'Remarkable people', + + 'SCIENCE': 'Education / Science / Factual topics', + 'NATURE': 'Nature / Animals / Environment', + 'TECHNOLOGY': 'Technology / Natural sciences', + 'DIOLOGY': 'Technology / Natural sciences', + 'MEDICINE': 'Medicine / Physiology / Psychology', + 'FOREIGN': 'Foreign countries / Expeditions', + 'SPIRITUAL': 'Social / Spiritual sciences', + 'FURTHER_EDUCATION': 'Further education', + 'LANGUAGES': 'Languages', + + 'HOBBIES': 'Leisure hobbies', + 'TRAVEL': 'Tourism / Travel', + 'HANDICRAFT': 'Handicraft', + 'MOTORING': 'Motoring', + 'FITNESS': 'Fitness and health', + 'COOKING': 'Cooking', + 'SHOPPING': 'Advertisement / Shopping', + 'GARDENING': 'Gardening' + } + +# Normal GENRES to TVHEADEND translation +TVHEADEND = { + 'Action': tvh_genres['THRILLER'], + 'Action sports': tvh_genres['SPORT'], + 'Adventure': tvh_genres['ADVENTURE'], + 'Agriculture': tvh_genres['NATURE'], + 'Animals': tvh_genres['NATURE'], + 'Anthology': tvh_genres['FILM'], + 'Art': tvh_genres['CULTURE'], + 'Baseball': tvh_genres['SPORT_TEAM'], + 'Basketball': tvh_genres['SPORT_TEAM'], + 'Biography': tvh_genres['VIP'], + 'Boxing': tvh_genres['SPORT'], + 'Cartoon': tvh_genres['CARTOON'], + 'Children': tvh_genres['KIDS'], + 'Classic Sport Event': tvh_genres['SPORT_SPECIAL'], + 'Comedy': tvh_genres['COMEDY'], + 'Comedy drama': tvh_genres['COMEDY'], + 'Community': tvh_genres['SOCIAL'], + 'Consumer': tvh_genres['SHOPPING'], + 'Cooking': tvh_genres['COOKING'], + 'Crime': tvh_genres['THRILLER'], + 'Crime drama': tvh_genres['THRILLER'], + 'Docudrama': tvh_genres['DOCUMENTARY'], + 'Documentary': tvh_genres['DOCUMENTARY'], + 'Drama': tvh_genres['MOVIE'], + 'Educational': tvh_genres['EDUCATIONAL'], + 'Entertainment': tvh_genres['GAME'], + 'Exercise': tvh_genres['FITNESS'], + 'Fantasy': tvh_genres['SF'], + 'financial': tvh_genres['ECONOMIC'], + 'Football': tvh_genres['FOOTBALL'], + 'Game show': tvh_genres['GAME'], + 'Golf': tvh_genres['SPORT_TEAM'], + 'Health': tvh_genres['MEDICINE'], + 'Historical drama': tvh_genres['HISTORICAL'], + 'Hockey': tvh_genres['SPORT_TEAM'], + 'Home improvement': tvh_genres['HANDICRAFT'], + 'Horror': tvh_genres['SF'], + 'House/garden': tvh_genres['GARDENING'], + 'How-to': tvh_genres['SCIENCE'], + 'Interview': tvh_genres['DEBATE'], + 'Law': tvh_genres['SOCIAL'], + 'Medical': tvh_genres['MEDICINE'], + 'Mixed martial arts': tvh_genres['MARTIAL_ARTS'], + 'Music': tvh_genres['MUSIC'], + 'Musical': tvh_genres['MUSIC'], + 'Musical comedy': tvh_genres['COMEDY'], + 'Mystery': tvh_genres['THRILLER'], + 'News': tvh_genres['NEWS'], + 'Newsmagazine': tvh_genres['NEWS_MAGAZINE'], + 'Olympics': tvh_genres['SPORT'], + 'Outdoors': tvh_genres['SPORT'], + 'Poker': tvh_genres['GAME'], + 'Pro wrestling': tvh_genres['MARTIAL_ARTS'], + 'Public affairs': tvh_genres['BROADCASTING'], + 'Reality': tvh_genres['GAME'], + 'Religious': tvh_genres['RELIGION'], + 'Romance': tvh_genres['ROMANCE'], + 'Romantic comedy': tvh_genres['ROMANCE'], + 'Science': tvh_genres['SCIENCE'], + 'Science fiction': tvh_genres['SF'], + 'Self improvement': tvh_genres['FURTHER_EDUCATION'], + 'Shopping': tvh_genres['SHOPPING'], + 'Sitcom': tvh_genres['COMEDY'], + 'Soap': tvh_genres['SOAP'], + 'Soccer': tvh_genres['FOOTBALL'], + 'Special': tvh_genres['NEW_MEDIA'], + 'Sports talk': tvh_genres['SPORT'], + 'Talk': tvh_genres['TALK_SHOW'], + 'Thriller': tvh_genres['THRILLER'], + 'Travel': tvh_genres['TRAVEL'], + 'Variety': tvh_genres['VARIETY'], + 'Weightlifting': tvh_genres['ATHLETICS'], + 'Western': tvh_genres['ADVENTURE'] +} diff --git a/lib/tvheadend/service/CoreELEC/autostart.sh b/lib/tvheadend/service/CoreELEC/autostart.sh new file mode 100644 index 0000000000000000000000000000000000000000..8ebf621727bd47c163b4e513b46cc612c25875a9 --- /dev/null +++ b/lib/tvheadend/service/CoreELEC/autostart.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# place file in the /storage/.config/ folder and make it executable +# This script is based on installing/unzipping the app in /storage/cabernet +# +( +sleep 10 +. /opt/etc/profile +cd /storage/cabernet +/opt/bin/python3 /storage/cabernet/tvh_main.py +) & diff --git a/lib/tvheadend/service/Unix/cabernet.service b/lib/tvheadend/service/Unix/cabernet.service new file mode 100644 index 0000000000000000000000000000000000000000..e039b9495336b86f00b0edc533d86e30bf2ebb3b --- /dev/null +++ b/lib/tvheadend/service/Unix/cabernet.service @@ -0,0 +1,34 @@ +####################################################################### +# OS: Ubuntu/Debian +# File location: /lib/systemd/system/ +# Update ExecStart to point to location of tvh_main.py +# Update User to set account to use to run service +# +# Once place, run the following command to add the service +# sudo systemctl enable locast.service +# sudo systemctl start locast.service +####################################################################### + +[Unit] +Description=Cabernet Service +Wants=network-online.target +After=network.target network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /home/hts/cabernet/tvh_main.py + +# Disable Python's buffering of STDOUT and STDERR, so that output from the +# service shows up immediately in systemd's logs +Environment=PYTHONUNBUFFERED=1 + +Restart=on-failure +RestartSec=54s + +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=cabernet +User=hts + +[Install] +WantedBy=multi-user.target diff --git a/lib/tvheadend/service/Unix/tv_grab_file b/lib/tvheadend/service/Unix/tv_grab_file new file mode 100644 index 0000000000000000000000000000000000000000..b0e57a24ecfb67a74bd675b9e94b9ad95e0f445a --- /dev/null +++ b/lib/tvheadend/service/Unix/tv_grab_file @@ -0,0 +1,54 @@ +#!#!/usr/bin/env bash +dflag= +vflag= +cflag= +if (( $# < 1 )) +then + cat ~/.xmltv/xmltv.xml + exit 0 +fi + +for arg +do + delim="" + case "$arg" in + #translate --gnu-long-options to -g (short options) + --description) args="${args}-d ";; + --version) args="${args}-v ";; + --capabilities) args="${args}-c ";; + #pass through anything else + *) [[ "${arg:0:1}" == "-" ]] || delim="\"" + args="${args}${delim}${arg}${delim} ";; + esac +done + +#Reset the positional parameters to the short options +eval set -- $args + +while getopts "dvc" option +do + case $option in + d) dflag=1;; + v) vflag=1;; + c) cflag=1;; + \?) printf "unknown option: -%s\n" $OPTARG + printf "Usage: %s: [--description] [--version] [--capabilities] \n" $(basename $0) + exit 2 + ;; + esac >&2 +done + +if [ "$dflag" ] +then + printf "tv_grab_file reads ~/.xmltv/xmltv.xml\n" +fi +if [ "$vflag" ] +then + printf "0.1\n" +fi +if [ "$cflag" ] +then + printf "baseline\n" +fi + +exit 0 \ No newline at end of file diff --git a/lib/tvheadend/service/Unix/tv_grab_url b/lib/tvheadend/service/Unix/tv_grab_url new file mode 100644 index 0000000000000000000000000000000000000000..24e96637cc71b704f823fc524af0b95f9314cbd3 --- /dev/null +++ b/lib/tvheadend/service/Unix/tv_grab_url @@ -0,0 +1,60 @@ +#!/usr/bin/env sh +dflag=; +vflag=; +cflag=; +uflag=; +if [ "$#" -lt 1 ]; +then + printf "At least one option is required\n"; + printf "Set -u [url] argument and try again\n"; + exit 2; +fi; + +while [ $# -gt 0 ]; +do + case "$1" in + -d | --description ) + dflag=1 + ;; + -v | --version ) + vflag=1 + ;; + -c | --capabilities ) + cflag=1;; + -u | --url ) + uflag=1; + shift; + url=$1; + ;; + -h ) + printf "Usage: %s: [--description] [--version] [--capabilities] [--url url]\n" $(basename $0); + exit 2 + ;; + * ) + printf "unknown option: %s\n" $1; + printf "Usage: %s: [--description] [--version] [--capabilities] [--url url]\n" $(basename $0); + exit 2 + ;; + esac; + shift; +done; + +if [ "$dflag" ]; +then + path=$(realpath $0); + printf "$path -u [url]\n"; +fi; +if [ "$vflag" ]; +then + printf "0.1\n"; +fi; +if [ "$cflag" ]; +then + printf "baseline\n"; +fi; +if [ "$uflag" ]; +then + wget -q -T 1200 -O - ${url}; +fi; + +exit 0; diff --git a/lib/tvheadend/service/Windows/nssm.exe b/lib/tvheadend/service/Windows/nssm.exe new file mode 100644 index 0000000000000000000000000000000000000000..8a1093cd3c948c1a4fd1597adff4115d3233d454 --- /dev/null +++ b/lib/tvheadend/service/Windows/nssm.exe @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f689ee9af94b00e9e3f0bb072b34caaf207f32dcb4f5782fc9ca351df9a06c97 +size 331264 diff --git a/lib/tvheadend/service/Windows/nssm.txt b/lib/tvheadend/service/Windows/nssm.txt new file mode 100644 index 0000000000000000000000000000000000000000..11fbac1a51d2635ea7e35c3176589cb87e952b9c --- /dev/null +++ b/lib/tvheadend/service/Windows/nssm.txt @@ -0,0 +1,692 @@ +NSSM: The Non-Sucking Service Manager +Version 2.24, 2014-08-31 + +NSSM is a service helper program similar to srvany and cygrunsrv. It can +start any application as an NT service and will restart the service if it +fails for any reason. + +NSSM also has a graphical service installer and remover. + +Full documentation can be found online at + + http://nssm.cc/ + +Since version 2.0, the GUI can be bypassed by entering all appropriate +options on the command line. + +Since version 2.1, NSSM can be compiled for x64 platforms. +Thanks Benjamin Mayrargue. + +Since version 2.2, NSSM can be configured to take different actions +based on the exit code of the managed application. + +Since version 2.3, NSSM logs to the Windows event log more elegantly. + +Since version 2.5, NSSM respects environment variables in its parameters. + +Since version 2.8, NSSM tries harder to shut down the managed application +gracefully and throttles restart attempts if the application doesn't run +for a minimum amount of time. + +Since version 2.11, NSSM respects srvany's AppEnvironment parameter. + +Since version 2.13, NSSM is translated into French. +Thanks François-Régis Tardy. + +Since version 2.15, NSSM is translated into Italian. +Thanks Riccardo Gusmeroli. + +Since version 2.17, NSSM can try to shut down console applications by +simulating a Control-C keypress. If they have installed a handler routine +they can clean up and shut down gracefully on receipt of the event. + +Since version 2.17, NSSM can redirect the managed application's I/O streams +to an arbitrary path. + +Since version 2.18, NSSM can be configured to wait a user-specified amount +of time for the application to exit when shutting down. + +Since version 2.19, many more service options can be configured with the +GUI installer as well as via the registry. + +Since version 2.19, NSSM can add to the service's environment by setting +AppEnvironmentExtra in place of or in addition to the srvany-compatible +AppEnvironment. + +Since version 2.22, NSSM can set the managed application's process priority +and CPU affinity. + +Since version 2.22, NSSM can apply an unconditional delay before restarting +an application which has exited. + +Since version 2.22, NSSM can rotate existing output files when redirecting I/O. + +Since version 2.22, NSSM can set service display name, description, startup +type, log on details and dependencies. + +Since version 2.22, NSSM can manage existing services. + + +Usage +----- +In the usage notes below, arguments to the program may be written in angle +brackets and/or square brackets. means you must insert the +appropriate string and [] means the string is optional. See the +examples below... + +Note that everywhere appears you may substitute the +service's display name. + + +Installation using the GUI +-------------------------- +To install a service, run + + nssm install + +You will be prompted to enter the full path to the application you wish +to run and any command line options to pass to that application. + +Use the system service manager (services.msc) to control advanced service +properties such as startup method and desktop interaction. NSSM may +support these options at a later time... + + +Installation using the command line +----------------------------------- +To install a service, run + + nssm install [] + +NSSM will then attempt to install a service which runs the named application +with the given options (if you specified any). + +Don't forget to enclose paths in "quotes" if they contain spaces! + +If you want to include quotes in the options you will need to """quote""" the +quotes. + + +Managing the service +-------------------- +NSSM will launch the application listed in the registry when you send it a +start signal and will terminate it when you send a stop signal. So far, so +much like srvany. But NSSM is the Non-Sucking service manager and can take +action if/when the application dies. + +With no configuration from you, NSSM will try to restart itself if it notices +that the application died but you didn't send it a stop signal. NSSM will +keep trying, pausing between each attempt, until the service is successfully +started or you send it a stop signal. + +NSSM will pause an increasingly longer time between subsequent restart attempts +if the service fails to start in a timely manner, up to a maximum of four +minutes. This is so it does not consume an excessive amount of CPU time trying +to start a failed application over and over again. If you identify the cause +of the failure and don't want to wait you can use the Windows service console +(where the service will be shown in Paused state) to send a continue signal to +NSSM and it will retry within a few seconds. + +By default, NSSM defines "a timely manner" to be within 1500 milliseconds. +You can change the threshold for the service by setting the number of +milliseconds as a REG_DWORD value in the registry at +HKLM\SYSTEM\CurrentControlSet\Services\\Parameters\AppThrottle. + +Alternatively, NSSM can pause for a configurable amount of time before +attempting to restart the application even if it successfully ran for the +amount of time specified by AppThrottle. NSSM will consult the REG_DWORD value +at HKLM\SYSTEM\CurrentControlSet\Services\\Parameters\AppRestartDelay +for the number of milliseconds to wait before attempting a restart. If +AppRestartDelay is set and the application is determined to be subject to +throttling, NSSM will pause the service for whichever is longer of the +configured restart delay and the calculated throttle period. + +If AppRestartDelay is missing or invalid, only throttling will be applied. + +NSSM will look in the registry under +HKLM\SYSTEM\CurrentControlSet\Services\\Parameters\AppExit for +string (REG_EXPAND_SZ) values corresponding to the exit code of the application. +If the application exited with code 1, for instance, NSSM will look for a +string value under AppExit called "1" or, if it does not find it, will +fall back to the AppExit (Default) value. You can find out the exit code +for the application by consulting the system event log. NSSM will log the +exit code when the application exits. + +Based on the data found in the registry, NSSM will take one of three actions: + +If the value data is "Restart" NSSM will try to restart the application as +described above. This is its default behaviour. + +If the value data is "Ignore" NSSM will not try to restart the application +but will continue running itself. This emulates the (usually undesirable) +behaviour of srvany. The Windows Services console would show the service +as still running even though the application has exited. + +If the value data is "Exit" NSSM will exit gracefully. The Windows Services +console would show the service as stopped. If you wish to provide +finer-grained control over service recovery you should use this code and +edit the failure action manually. Please note that Windows versions prior +to Vista will not consider such an exit to be a failure. On older versions +of Windows you should use "Suicide" instead. + +If the value data is "Suicide" NSSM will simulate a crash and exit without +informing the service manager. This option should only be used for +pre-Vista systems where you wish to apply a service recovery action. Note +that if the monitored application exits with code 0, NSSM will only honour a +request to suicide if you explicitly configure a registry key for exit code 0. +If only the default action is set to Suicide NSSM will instead exit gracefully. + + +Application priority +-------------------- +NSSM can set the priority class of the managed application. NSSM will look in +the registry under HKLM\SYSTEM\CurrentControlSet\Services\\Parameters +for the REG_DWORD entry AppPriority. Valid values correspond to arguments to +SetPriorityClass(). If AppPriority() is missing or invalid the +application will be launched with normal priority. + + +Processor affinity +------------------ +NSSM can set the CPU affinity of the managed application. NSSM will look in +the registry under HKLM\SYSTEM\CurrentControlSet\Services\\Parameters +for the REG_SZ entry AppAffinity. It should specify a comma-separated listed +of zero-indexed processor IDs. A range of processors may optionally be +specified with a dash. No other characters are allowed in the string. + +For example, to specify the first; second; third and fifth CPUs, an appropriate +AppAffinity would be 0-2,4. + +If AppAffinity is missing or invalid, NSSM will not attempt to restrict the +application to specific CPUs. + +Note that the 64-bit version of NSSM can configure a maximum of 64 CPUs in this +way and that the 32-bit version can configure a maxium of 32 CPUs even when +running on 64-bit Windows. + + +Stopping the service +-------------------- +When stopping a service NSSM will attempt several different methods of killing +the monitored application, each of which can be disabled if necessary. + +First NSSM will attempt to generate a Control-C event and send it to the +application's console. Batch scripts or console applications may intercept +the event and shut themselves down gracefully. GUI applications do not have +consoles and will not respond to this method. + +Secondly NSSM will enumerate all windows created by the application and send +them a WM_CLOSE message, requesting a graceful exit. + +Thirdly NSSM will enumerate all threads created by the application and send +them a WM_QUIT message, requesting a graceful exit. Not all applications' +threads have message queues; those which do not will not respond to this +method. + +Finally NSSM will call TerminateProcess() to request that the operating +system forcibly terminate the application. TerminateProcess() cannot be +trapped or ignored, so in most circumstances the application will be killed. +However, there is no guarantee that it will have a chance to perform any +tidyup operations before it exits. + +Any or all of the methods above may be disabled. NSSM will look for the +HKLM\SYSTEM\CurrentControlSet\Services\\Parameters\AppStopMethodSkip +registry value which should be of type REG_DWORD set to a bit field describing +which methods should not be applied. + + If AppStopMethodSkip includes 1, Control-C events will not be generated. + If AppStopMethodSkip includes 2, WM_CLOSE messages will not be posted. + If AppStopMethodSkip includes 4, WM_QUIT messages will not be posted. + If AppStopMethodSkip includes 8, TerminateProcess() will not be called. + +If, for example, you knew that an application did not respond to Control-C +events and did not have a thread message queue, you could set AppStopMethodSkip +to 5 and NSSM would not attempt to use those methods to stop the application. + +Take great care when including 8 in the value of AppStopMethodSkip. If NSSM +does not call TerminateProcess() it is possible that the application will not +exit when the service stops. + +By default NSSM will allow processes 1500ms to respond to each of the methods +described above before proceeding to the next one. The timeout can be +configured on a per-method basis by creating REG_DWORD entries in the +registry under HKLM\SYSTEM\CurrentControlSet\Services\\Parameters. + + AppStopMethodConsole + AppStopMethodWindow + AppStopMethodThreads + +Each value should be set to the number of milliseconds to wait. Please note +that the timeout applies to each process in the application's process tree, +so the actual time to shutdown may be longer than the sum of all configured +timeouts if the application spawns multiple subprocesses. + + +Console window +-------------- +By default, NSSM will create a console window so that applications which +are capable of reading user input can do so - subject to the service being +allowed to interact with the desktop. + +Creation of the console can be suppressed by setting the integer (REG_DWORD) +HKLM\SYSTEM\CurrentControlSet\Services\\Parameters\AppNoConsole +registry value to 1. + + +I/O redirection +--------------- +NSSM can redirect the managed application's I/O to any path capable of being +opened by CreateFile(). This enables, for example, capturing the log output +of an application which would otherwise only write to the console or accepting +input from a serial port. + +NSSM will look in the registry under +HKLM\SYSTEM\CurrentControlSet\Services\\Parameters for the keys +corresponding to arguments to CreateFile(). All are optional. If no path is +given for a particular stream it will not be redirected. If a path is given +but any of the other values are omitted they will be receive sensible defaults. + + AppStdin: Path to receive input. + AppStdout: Path to receive output. + AppStderr: Path to receive error output. + +Parameters for CreateFile() are providing with the "AppStdinShareMode", +"AppStdinCreationDisposition" and "AppStdinFlagsAndAttributes" values (and +analogously for stdout and stderr). + +In general, if you want the service to log its output, set AppStdout and +AppStderr to the same path, eg C:\Users\Public\service.log, and it should +work. Remember, however, that the path must be accessible to the user +running the service. + + +File rotation +------------- +When using I/O redirection, NSSM can rotate existing output files prior to +opening stdout and/or stderr. An existing file will be renamed with a +suffix based on the file's last write time, to millisecond precision. For +example, the file nssm.log might be rotated to nssm-20131221T113939.457.log. + +NSSM will look in the registry under +HKLM\SYSTEM\CurrentControlSet\Services\\Parameters for REG_DWORD +entries which control how rotation happens. + +If AppRotateFiles is missing or set to 0, rotation is disabled. Any non-zero +value enables rotation. + +If AppRotateSeconds is non-zero, a file will not be rotated if its last write +time is less than the given number of seconds in the past. + +If AppRotateBytes is non-zero, a file will not be rotated if it is smaller +than the given number of bytes. 64-bit file sizes can be handled by setting +a non-zero value of AppRotateBytesHigh. + +Rotation is independent of the CreateFile() parameters used to open the files. +They will be rotated regardless of whether NSSM would otherwise have appended +or replaced them. + +NSSM can also rotate files which hit the configured size threshold while the +service is running. Additionally, you can trigger an on-demand rotation by +running the command + + nssm rotate + +On-demand rotations will happen after the next line of data is read from +the managed application, regardless of the value of AppRotateBytes. Be aware +that if the application is not particularly verbose the rotation may not +happen for some time. + +To enable online and on-demand rotation, set AppRotateOnline to a non-zero +value. + +Note that online rotation requires NSSM to intercept the application's I/O +and create the output files on its behalf. This is more complex and +error-prone than simply redirecting the I/O streams before launching the +application. Therefore online rotation is not enabled by default. + + +Environment variables +--------------------- +NSSM can replace or append to the managed application's environment. Two +multi-valued string (REG_MULTI_SZ) registry values are recognised under +HKLM\SYSTEM\CurrentControlSet\Services\\Parameters. + +AppEnvironment defines a list of environment variables which will override +the service's environment. AppEnvironmentExtra defines a list of +environment variables which will be added to the service's environment. + +Each entry in the list should be of the form KEY=VALUE. It is possible to +omit the VALUE but the = symbol is mandatory. + +Environment variables listed in both AppEnvironment and AppEnvironmentExtra +are subject to normal expansion, so it is possible, for example, to update the +system path by setting "PATH=C:\bin;%PATH%" in AppEnvironmentExtra. Variables +are expanded in the order in which they appear, so if you want to include the +value of one variable in another variable you should declare the dependency +first. + +Because variables defined in AppEnvironment override the existing +environment it is not possible to refer to any variables which were previously +defined. + +For example, the following AppEnvironment block: + + PATH=C:\Windows\System32;C:\Windows + PATH=C:\bin;%PATH% + +Would result in a PATH of "C:\bin;C:\Windows\System32;C:\Windows" as expected. + +Whereas the following AppEnvironment block: + + PATH=C:\bin;%PATH% + +Would result in a path containing only C:\bin and probably cause the +application to fail to start. + +Most people will want to use AppEnvironmentExtra exclusively. srvany only +supports AppEnvironment. + + +Managing services using the GUI +------------------------------- +NSSM can edit the settings of existing services with the same GUI that is +used to install them. Run + + nssm edit + +to bring up the GUI. + +NSSM offers limited editing capabilities for services other than those which +run NSSM itself. When NSSM is asked to edit a service which does not have +the App* registry settings described above, the GUI will allow editing only +system settings such as the service display name and description. + + +Managing services using the command line +---------------------------------------- +NSSM can retrieve or set individual service parameters from the command line. +In general the syntax is as follows, though see below for exceptions. + + nssm get + + nssm set + +Parameters can also be reset to their default values. + + nssm reset + +The parameter names recognised by NSSM are the same as the registry entry +names described above, eg AppDirectory. + +NSSM offers limited editing capabilities for Services other than those which +run NSSM itself. The parameters recognised are as follows: + + Description: Service description. + DisplayName: Service display name. + ImagePath: Path to the service executable. + ObjectName: User account which runs the service. + Name: Service key name. + Start: Service startup type. + Type: Service type. + +These correspond to the registry values under the service's key +HKLM\SYSTEM\CurrentControlSet\Services\. + + +Note that NSSM will concatenate all arguments passed on the command line +with spaces to form the value to set. Thus the following two invocations +would have the same effect. + + nssm set Description "NSSM managed service" + + nssm set Description NSSM managed service + + +Non-standard parameters +----------------------- +The AppEnvironment and AppEnvironmentExtra parameters recognise an +additional argument when querying the environment. The following syntax +will print all extra environment variables configured for a service + + nssm get AppEnvironmentExtra + +whereas the syntax below will print only the value of the CLASSPATH +variable if it is configured in the environment block, or the empty string +if it is not configured. + + nssm get AppEnvironmentExtra CLASSPATH + +When setting an environment block, each variable should be specified as a +KEY=VALUE pair in separate command line arguments. For example: + + nssm set AppEnvironment CLASSPATH=C:\Classes TEMP=C:\Temp + + +The AppExit parameter requires an additional argument specifying the exit +code to get or set. The default action can be specified with the string +Default. + +For example, to get the default exit action for a service you should run + + nssm get AppExit Default + +To get the exit action when the application exits with exit code 2, run + + nssm get AppExit 2 + +Note that if no explicit action is configured for a specified exit code, +NSSM will print the default exit action. + +To set configure the service to stop when the application exits with an +exit code of 2, run + + nssm set AppExit 2 Exit + + +The AppPriority parameter is used to set the priority class of the +managed application. Valid priorities are as follows: + + REALTIME_PRIORITY_CLASS + HIGH_PRIORITY_CLASS + ABOVE_NORMAL_PRIORITY_CLASS + NORMAL_PRIORITY_CLASS + BELOW_NORMAL_PRIORITY_CLASS + IDLE_PRIORITY_CLASS + + +The DependOnGroup and DependOnService parameters are used to query or set +the dependencies for the service. When setting dependencies, each service +or service group (preceded with the + symbol) should be specified in +separate command line arguments. For example: + + nssm set DependOnService RpcSs LanmanWorkstation + + +The Name parameter can only be queried, not set. It returns the service's +registry key name. This may be useful to know if you take advantage of +the fact that you can substitute the service's display name anywhere where +the syntax calls for . + + +The ObjectName parameter requires an additional argument only when setting +a username. The additional argument is the password of the user. + +To retrieve the username, run + + nssm get ObjectName + +To set the username and password, run + + nssm set ObjectName + +Note that the rules of argument concatenation still apply. The following +invocation is valid and will have the expected effect. + + nssm set ObjectName correct horse battery staple + +The following well-known usernames do not need a password. The password +parameter can be omitted when using them: + + "LocalSystem" aka "System" aka "NT Authority\System" + "LocalService" aka "Local Service" aka "NT Authority\Local Service" + "NetworkService" aka "Network Service" aka "NT Authority\Network Service" + + +The Start parameter is used to query or set the startup type of the service. +Valid service startup types are as follows: + + SERVICE_AUTO_START: Automatic startup at boot. + SERVICE_DELAYED_START: Delayed startup at boot. + SERVICE_DEMAND_START: Manual service startup. + SERVICE_DISABLED: The service is disabled. + +Note that SERVICE_DELAYED_START is not supported on versions of Windows prior +to Vista. NSSM will set the service to automatic startup if delayed start is +unavailable. + + +The Type parameter is used to query or set the service type. NSSM recognises +all currently documented service types but will only allow setting one of two +types: + + SERVICE_WIN32_OWN_PROCESS: A standalone service. This is the default. + SERVICE_INTERACTIVE_PROCESS: A service which can interact with the desktop. + +Note that a service may only be configured as interactive if it runs under +the LocalSystem account. The safe way to configure an interactive service +is in two stages as follows. + + nssm reset ObjectName + nssm set Type SERVICE_INTERACTIVE_PROCESS + + +Controlling services using the command line +------------------------------------------- +NSSM offers rudimentary service control features. + + nssm start + + nssm restart + + nssm stop + + nssm status + + +Removing services using the GUI +------------------------------- +NSSM can also remove services. Run + + nssm remove + +to remove a service. You will prompted for confirmation before the service +is removed. Try not to remove essential system services... + + +Removing service using the command line +--------------------------------------- +To remove a service without confirmation from the GUI, run + + nssm remove confirm + +Try not to remove essential system services... + + +Logging +------- +NSSM logs to the Windows event log. It registers itself as an event log source +and uses unique event IDs for each type of message it logs. New versions may +add event types but existing event IDs will never be changed. + +Because of the way NSSM registers itself you should be aware that you may not +be able to replace the NSSM binary if you have the event viewer open and that +running multiple instances of NSSM from different locations may be confusing if +they are not all the same version. + + +Example usage +------------- +To install an Unreal Tournament server: + + nssm install UT2004 c:\games\ut2004\system\ucc.exe server + +To run the server as the "games" user: + + nssm set UT2004 ObjectName games password + +To configure the server to log to a file: + + nssm set UT2004 AppStdout c:\games\ut2004\service.log + +To restrict the server to a single CPU: + + nssm set UT2004 AppAffinity 0 + +To remove the server: + + nssm remove UT2004 confirm + +To find out the service name of a service with a display name: + + nssm get "Background Intelligent Transfer Service" Name + + +Building NSSM from source +------------------------- +NSSM is known to compile with Visual Studio 2008 and later. Older Visual +Studio releases may or may not work if you install an appropriate SDK and +edit the nssm.vcproj and nssm.sln files to set a lower version number. +They are known not to work with default settings. + +NSSM will also compile with Visual Studio 2010 but the resulting executable +will not run on versions of Windows older than XP SP2. If you require +compatiblity with older Windows releases you should change the Platform +Toolset to v90 in the General section of the project's Configuration +Properties. + + +Credits +------- +Thanks to Bernard Loh for finding a bug with service recovery. +Thanks to Benjamin Mayrargue (www.softlion.com) for adding 64-bit support. +Thanks to Joel Reingold for spotting a command line truncation bug. +Thanks to Arve Knudsen for spotting that child processes of the monitored +application could be left running on service shutdown, and that a missing +registry value for AppDirectory confused NSSM. +Thanks to Peter Wagemans and Laszlo Keresztfalvi for suggesting throttling +restarts. +Thanks to Eugene Lifshitz for finding an edge case in CreateProcess() and for +advising how to build messages.mc correctly in paths containing spaces. +Thanks to Rob Sharp for pointing out that NSSM did not respect the +AppEnvironment registry value used by srvany. +Thanks to Szymon Nowak for help with Windows 2000 compatibility. +Thanks to François-Régis Tardy and Gildas le Nadan for French translation. +Thanks to Emilio Frini for spotting that French was inadvertently set as +the default language when the user's display language was not translated. +Thanks to Riccardo Gusmeroli and Marco Certelli for Italian translation. +Thanks to Eric Cheldelin for the inspiration to generate a Control-C event +on shutdown. +Thanks to Brian Baxter for suggesting how to escape quotes from the command +prompt. +Thanks to Russ Holmann for suggesting that the shutdown timeout be configurable. +Thanks to Paul Spause for spotting a bug with default registry entries. +Thanks to BUGHUNTER for spotting more GUI bugs. +Thanks to Doug Watson for suggesting file rotation. +Thanks to Арслан Сайдуганов for suggesting setting process priority. +Thanks to Robert Middleton for suggestion and draft implementation of process +affinity support. +Thanks to Andrew RedzMax for suggesting an unconditional restart delay. +Thanks to Bryan Senseman for noticing that applications with redirected stdout +and/or stderr which attempt to read from stdin would fail. +Thanks to Czenda Czendov for help with Visual Studio 2013 and Server 2012R2. +Thanks to Alessandro Gherardi for reporting and draft fix of the bug whereby +the second restart of the application would have a corrupted environment. +Thanks to Hadrien Kohl for suggesting to disable the console window's menu. +Thanks to Allen Vailliencourt for noticing bugs with configuring the service to +run under a local user account. +Thanks to Sam Townsend for noticing a regression with TerminateProcess(). + +Licence +------- +NSSM is public domain. You may unconditionally use it and/or its source code +for any purpose you wish. diff --git a/lib/updater/cabernet.py b/lib/updater/cabernet.py new file mode 100644 index 0000000000000000000000000000000000000000..907e200a1c37c2ee4c7c242a94f38a1f0fd3a056 --- /dev/null +++ b/lib/updater/cabernet.py @@ -0,0 +1,272 @@ +""" +MIT License + +Copyright (C) 2021 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import glob +import importlib +import importlib.resources +import json +import logging +import os +import pathlib +import re +import time +import urllib.request +import shutil +import zipfile + +import lib.common.utils as utils +import lib.db.datamgmt.backups as backups +from lib.db.db_plugins import DBPlugins +from lib.common.decorators import handle_url_except +from lib.common.decorators import handle_json_except +from lib.common.tmp_mgmt import TMPMgmt + +TMP_ZIPFILE = utils.CABERNET_ID + '.zip' + +class CabernetUpgrade: + + def __init__(self, _plugins): + self.logger = logging.getLogger(__name__) + self.version_re = re.compile(r'(\d+\.\d+)\.\d+') + self.plugins = _plugins + self.config_obj = _plugins.config_obj + self.config = _plugins.config_obj.data + self.plugin_db = DBPlugins(self.config) + self.tmp_mgmt = TMPMgmt(self.config) + + def update_version_info(self): + """ + Updates the database with the latest version release data + from github for cabernet and plugins loaded + """ + manifest = self.import_manifest() + release_data_list = self.github_releases(manifest) + if release_data_list is not None: + current_version = utils.VERSION + last_version = release_data_list[0]['tag_name'] + last_stable_version = release_data_list[0]['tag_name'] + next_version = self.get_next_release(release_data_list) + manifest['version']['current'] = current_version + manifest['version']['next'] = next_version + manifest['version']['latest'] = last_version + manifest['version']['installed'] = True + self.save_manifest(manifest) + # need to have the task take at least 1 second to register the time + time.sleep(1) + + def import_manifest(self): + """ + Loads the manifest for cabernet + """ + json_settings = self.plugin_db.get_repos(utils.CABERNET_ID) + if json_settings: + json_settings = json_settings[0] + return json_settings + + def load_manifest(self): + """ + Loads the cabernet manifest from DB + """ + manifest_list = self.plugin_db.get_repos(utils.CABERNET_ID) + if manifest_list is None: + return None + else: + return manifest_list[0] + + def save_manifest(self, _manifest): + """ + Saves to DB the manifest for cabernet + """ + self.plugin_db.save_repo(_manifest) + + def github_releases(self, _manifest): + url = ''.join([ + _manifest['dir']['github_repo_' + self.config['main']['upgrade_quality']], + '/releases' + ]) + return self.get_uri_data(url, 2) + + @handle_json_except + @handle_url_except + def get_uri_data(self, _uri, _retries): + header = {'Content-Type': 'application/json', + 'User-agent': utils.DEFAULT_USER_AGENT} + req = urllib.request.Request(_uri, headers=header) + with urllib.request.urlopen(req, timeout=10.0) as resp: + x = json.load(resp) + return x + + def get_next_release(self, release_data_list): + current_version = self.config['main']['version'] + cur_version_float = utils.get_version_index(current_version) + next_version_int = (int(cur_version_float/100)+2)*100 + prev_version = release_data_list[0]['tag_name'] + data = None + for data in release_data_list: + version_float = utils.get_version_index(data['tag_name']) + if version_float < next_version_int: + break + prev_version = data['tag_name'] + return prev_version + + def get_stable_release(self, release_data_list): + """ + Get the latest stable release with the format z.y.x.w without additional text... + + """ + pass + + + + + def upgrade_app(self, _web_status): + """ + Initial request to perform an upgrade + """ + c_manifest = self.load_manifest() + if c_manifest is None: + self.logger.info('Cabernet manifest not found, aborting') + _web_status.data += 'Cabernet manifest not found, aborting
    \r\n' + return False + if not c_manifest['version'].get('next'): + return False + if c_manifest['version'].get('next') == c_manifest['version'].get('current'): + self.logger.info('Cabernet is on the current version, not upgrading') + _web_status.data += 'Cabernet is on the current version, not upgrading
    \r\n' + return False + + # This checks to see if additional files or folders are in the + # basedir area. if so, abort upgrade. + # It is basically for the case where we have the wrong directory + _web_status.data += 'Checking current install area for expected files...
    \r\n' + if not self.check_expected_files(_web_status): + return False + + b = backups.Backups(self.plugins) + + # recursively check all folders from the basedir to see if they are writable + _web_status.data += 'Checking write permissions...
    \r\n' + resp = b.check_code_write_permissions() + if resp is not None: + _web_status.data += resp + return False + + # simple call to run a backup of the data and source + # use a direct call to the backup methods instead of calling the scheduler + _web_status.data += 'Creating backup of code and data...
    \r\n' + if not b.backup_all(): + _web_status.data += 'Backup failed, aborting upgrade
    \r\n' + return False + + _web_status.data += 'Downloading new version from website...
    \r\n' + if not self.download_zip('/'.join([ + c_manifest['dir']['github_repo_' + self.config['main']['upgrade_quality']], + 'zipball', c_manifest['version']['next'] + ]), 2): + _web_status.data += 'Download of the new version failed, aborting upgrade
    \r\n' + return False + + # skip integrity checks using SHA256 or SHA512 for now + + # Unzips the downloaded file to a temp area and check the version + # contained in the utils.py that it is the same as expected. + _web_status.data += 'Extracting zip...
    \r\n' + # folder is relative to tmp folder + unpacked_code = self.extract_code() + if unpacked_code is None: + _web_status.data += 'Extracting from zip failed, aborting upgrade
    \r\n' + return False + + # Deletes the non-data and non-plugin files + # maybe save the pycache folders? + # this helps in case a file has no modify permission. + # it can still be removed and added. + # *.py, *.html, *.js, *.png, ... + + _web_status.data += 'Deleting old code...
    \r\n' + if b.delete_code() is None: + _web_status.data += 'Deleting old files failed, aborting upgrade
    \r\n' + return False + + # does a move of the unzipped files to the source area + _web_status.data += 'Moving new code in place...
    \r\n' + b.restore_code(unpacked_code) + + return True + + def check_expected_files(self, _web_status): + """ + Check the base directory files to see if all are expected. + """ + files_present = ['build', 'lib', 'misc', + '.dockerignore', '.gitignore', 'CHANGELOG.md', 'CONTRIBUTING.md', + 'Docker_entrypoint.sh', 'Dockerfile', 'Dockerfile_tvh_crypt.alpine', + 'Dockerfile_tvh_crypt.slim-buster', 'LICENSE', 'README.md', + 'TVHEADEND.md', 'docker-compose.yml', 'requirements.txt', 'tvh_main.py', + 'data', 'config.ini', 'is_container', '.git', 'cabernet.url', 'ffmpeg', + 'README.txt', 'uninst.exe'] + + files_present.extend([self.config['paths']['internal_plugins_pkg'], self.config['paths']['external_plugins_pkg']]) + + filelist = [os.path.basename(x) for x in + glob.glob(os.path.join(self.config['paths']['main_dir'], '*'))] + response = True + for file in filelist: + if file not in files_present: + _web_status.data += '#### Extra file(s) found in install directory, aborting upgrade. FILE: {}
    \r\n'\ + .format(file) + response = False + return response + + @handle_json_except + @handle_url_except + def download_zip(self, _zip_url, _retries): + + buf_size = 2 * 16 * 16 * 1024 + save_path = pathlib.Path(self.config['paths']['tmp_dir']).joinpath(TMP_ZIPFILE) + h = {'Content-Type': 'application/zip', 'User-agent': utils.DEFAULT_USER_AGENT} + req = urllib.request.Request(_zip_url, headers=h) + with urllib.request.urlopen(req) as resp: + with open(save_path, 'wb') as out_file: + while True: + chunk = resp.read(buf_size) + if not chunk: + break + out_file.write(chunk) + return True + + def extract_code(self): + try: + file_to_extract = pathlib.Path(self.config['paths']['tmp_dir']).joinpath(TMP_ZIPFILE) + out_folder = pathlib.Path(self.config['paths']['tmp_dir']).joinpath('code') + with zipfile.ZipFile(file_to_extract, 'r') as z: + files = z.namelist() + top_folder = files[0] + z.extractall(out_folder) + return pathlib.Path('code', top_folder) + except (zipfile.BadZipFile, FileNotFoundError): + return None + + def cleanup_tmp(self): + dir_ = self.config['paths']['tmp_dir'] + for files in os.listdir(dir_): + path = os.path.join(dir_, files) + try: + shutil.rmtree(path) + except OSError: + os.remove(path) diff --git a/lib/updater/patcher.py b/lib/updater/patcher.py new file mode 100644 index 0000000000000000000000000000000000000000..048ae820b7a7c8cee90860c5bfb9041f5c2affd7 --- /dev/null +++ b/lib/updater/patcher.py @@ -0,0 +1,90 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + + +import configparser +import logging +import time +import traceback + +from lib.plugins.plugin_manager.plugin_manager import PluginManager +from lib.db.db_plugins import DBPlugins +from lib.db.db_scheduler import DBScheduler + + +REQUIRED_VERSION = '0.9.14' +LOGGER = None + + +def patch_upgrade(_config_obj, _new_version): + """ + This method is called when a cabernet upgrade is requested. Versions are + major.minor.patch + The system is setup to stop at each major or minor increment and + perform an upgrade. This does imply that patch upgrades do not require changes to the data. + To make sure this only executes associated with a specific version, the version + it is associated is tested with this new version. + """ + global LOGGER + if not LOGGER: + LOGGER = logging.getLogger(__name__) + + results = '' + if _new_version.startswith(REQUIRED_VERSION): + LOGGER.info('Applying patches to version: {}'.format(REQUIRED_VERSION)) + + try: + try: + _config_obj.config_handler.remove_option('streams', 'stream_timeout') + except configparser.NoSectionError: + pass + _config_obj.config_handler.remove_option('logger_root', 'level') + _config_obj.config_handler.set('logger_root', 'level', 'TRACE') + + + except Exception: + # Make sure that the patcher exits normally so the maintenance flag is removed + LOGGER.warning(traceback.format_exc()) + return results + + +def move_key(_config_obj, _key): + find_key_by_section(_config_obj, _key, 'plutotv') + find_key_by_section(_config_obj, _key, 'xumo') + + +def find_key_by_section(_config_obj, _key, _section): + global LOGGER + if not LOGGER: + LOGGER = logging.getLogger(__name__) + if _section in _config_obj.data: + if _key in _config_obj.data[_section]: + LOGGER.info('Moving setting {}:{} to instance'.format(_section, _key)) + value = _config_obj.data[_section][_key] + sections = find_instance(_config_obj.data, _section) + for section in sections: + _config_obj.write(section, _key, value) + _config_obj.write(_section, _key, None) + + +def find_instance(_config, _plugin_name): + sections = [] + for section in _config.keys(): + if section.startswith(_plugin_name + '_'): + sections.append(section) + return sections diff --git a/lib/updater/plugins.py b/lib/updater/plugins.py new file mode 100644 index 0000000000000000000000000000000000000000..76440a11d0c32450f76654a7f9098865a6031db0 --- /dev/null +++ b/lib/updater/plugins.py @@ -0,0 +1,53 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + + +import logging + +from lib.db.db_plugins import DBPlugins +from lib.plugins.plugin_manager.plugin_manager import PluginManager + + +class PluginsUpgrade: + + def __init__(self, _plugins): + self.logger = logging.getLogger(__name__) + self.config_obj = _plugins.config_obj + self.config = _plugins.config_obj.data + self.plugin_db = DBPlugins(self.config) + self.pm = PluginManager(None, self.config_obj) + + + def upgrade_plugins(self, _web_status): + _web_status.data += '#### Checking Plugins ####
    \r\n' + plugin_defns = self.plugin_db.get_plugins(True) + if not plugin_defns: + return True + + for p_defn in plugin_defns: + if not p_defn.get('external'): + continue + if p_defn['version']['current'] == p_defn['version']['latest']: + continue + # upgrade available + _web_status.data += self.pm.delete_plugin(p_defn['repoid'], p_defn['id']) + _web_status.data += self.pm.install_plugin(p_defn['repoid'], p_defn['id']) + _web_status.data += '
    \r\n#### Plugin Upgrades Finished ####
    \r\n' + + return True + diff --git a/lib/updater/updater.py b/lib/updater/updater.py new file mode 100644 index 0000000000000000000000000000000000000000..9fdc4dc46761084c03e35ce37d3fdeeb738ac1ab --- /dev/null +++ b/lib/updater/updater.py @@ -0,0 +1,185 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import importlib +import importlib.resources +import json +import logging +import re +import time +from threading import Thread + +import lib.common.utils as utils +from lib.db.db_scheduler import DBScheduler +from lib.db.db_plugins import DBPlugins +from lib.common.decorators import getrequest +from lib.web.pages.templates import web_templates +from lib.updater.cabernet import CabernetUpgrade +from lib.updater.plugins import PluginsUpgrade +from lib.common.string_obj import StringObj +from lib.common.tmp_mgmt import TMPMgmt +from lib.updater import cabernet +from lib.plugins.repo_handler import RepoHandler + +STATUS = StringObj() +IS_UPGRADING = False + + +@getrequest.route('/api/upgrade') +def upgrade(_webserver): + global STATUS + global IS_UPGRADING + v = Updater(_webserver.plugins) + try: + if 'id' in _webserver.query_data: + if _webserver.query_data['id'] != utils.CABERNET_ID: + _webserver.do_mime_response(501, 'text/html', + web_templates['htmlError'].format('501 - Invalid ID')) + return + if not IS_UPGRADING: + IS_UPGRADING = True + v.sched_queue = _webserver.sched_queue + STATUS.data = '' + t = Thread(target=v.upgrade_app, args=(_webserver.query_data['id'],)) + t.start() + _webserver.do_mime_response(200, 'text/html', ''.join([STATUS.data])) + return + else: + _webserver.do_mime_response(501, 'text/html', + web_templates['htmlError'].format('404 - Unknown action')) + except KeyError: + _webserver.do_mime_response(501, 'text/html', + web_templates['htmlError'].format('501 - Badly formed request')) + + +def check_for_updates(plugins): + v = Updater(plugins) + v.update_version_info() + return True + + +class Updater: + + def __init__(self, _plugins): + self.logger = logging.getLogger(__name__) + self.version_re = re.compile(r'(\d+\.\d+)\.\d+') + self.plugins = _plugins + self.config_obj = _plugins.config_obj + self.config = _plugins.config_obj.data + self.plugin_db = DBPlugins(self.config) + self.sched_queue = None + self.tmp_mgmt = TMPMgmt(self.config) + + def scheduler_tasks(self): + scheduler_db = DBScheduler(self.config) + if scheduler_db.save_task( + 'Applications', + 'Check for Updates', + 'internal', + None, + 'lib.updater.updater.check_for_updates', + 99, + 'inline', + 'Checks cabernet and all plugins for updated versions' + ): + scheduler_db.save_trigger( + 'Applications', + 'Check for Updates', + 'interval', + interval=2850, + randdur=60 + ) + scheduler_db.save_trigger( + 'Applications', + 'Check for Updates', + 'startup') + + def update_version_info(self): + self.logger.info('Updating Repo Cabernet-Repository versions') + self.repos = RepoHandler(self.config_obj) + self.repos.load_cabernet_repo() + self.logger.info('Updating Cabernet versions') + c = CabernetUpgrade(self.plugins) + c.update_version_info() + + def import_manifest(self): + """ + Loads the manifest for cabernet from a file + """ + json_settings = importlib.resources.read_text(self.config['paths']['resources_pkg'], utils.CABERNET_REPO) + settings = json.loads(json_settings) + return settings + + def load_manifest(self, _manifest): + """ + Loads the cabernet manifest from DB + """ + return self.plugin_db.get_plugins(_installed=True, _namespace=_manifest)[0] + + def save_manifest(self, _manifest): + """ + Saves to DB the manifest for cabernet + """ + self.plugin_db.save_plugin(_manifest) + + def upgrade_app(self, _id): + """ + Initial request to perform an upgrade + """ + global STATUS + global IS_UPGRADING + + STATUS.data = 'Starting upgrade...
    \r\n' + + # upgrade the main cabernet app + app = CabernetUpgrade(self.plugins) + if not app.upgrade_app(STATUS): + STATUS.data += '' + time.sleep(1) + IS_UPGRADING = False + return + + # upgrade the installed external plugins + p = PluginsUpgrade(self.plugins) + if not p.upgrade_plugins(STATUS): + STATUS.data += '' + time.sleep(1) + IS_UPGRADING = False + return + + STATUS.data += 'Entering Maintenance Mode...
    \r\n' + # make sure the config_handler really has the config data uploaded + self.config_obj.config_handler.read(self.config_obj.data['paths']['config_file']) + self.config_obj.write('main', 'maintenance_mode', True) + + STATUS.data += 'Restarting app in 3...
    \r\n' + self.tmp_mgmt.cleanup_tmp() + time.sleep(0.8) + STATUS.data += '2...
    \r\n' + time.sleep(0.8) + STATUS.data += '1...
    \r\n' + STATUS.data += '' + time.sleep(1) + self.restart_app() + IS_UPGRADING = False + + def restart_app(self): + # get schedDB and find restart taskid. + scheduler_db = DBScheduler(self.config) + task = scheduler_db.get_tasks('Applications', 'Restart')[0] + self.sched_queue.put({'cmd': 'runtask', 'taskid': task['taskid']}) diff --git a/lib/web/__init__.py b/lib/web/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/__init__.py b/lib/web/htdocs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/html/__init__.py b/lib/web/htdocs/html/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/html/index.html b/lib/web/htdocs/html/index.html new file mode 100644 index 0000000000000000000000000000000000000000..03dbee8c09b317bb22b036f7efb78e60ff8286ad --- /dev/null +++ b/lib/web/htdocs/html/index.html @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cabernet + + + + + + + + + + +
    +
    + + +
    + + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + + + \ No newline at end of file diff --git a/lib/web/htdocs/html/links.html b/lib/web/htdocs/html/links.html new file mode 100644 index 0000000000000000000000000000000000000000..257cb22bdc85ecbad3fa3fa70288fafe88411c50 --- /dev/null +++ b/lib/web/htdocs/html/links.html @@ -0,0 +1,47 @@ + + Cabernet Home Page + + +

    Cabernet

    +

    This is a list of interfaces provided by Cabernet. + If it states HDHR, then the interface matches that of HDHomerun tuners. +

    + + All links above have the same ability to apply to any plugin. You can request the data filtered + for a plugin or for an instance of the plugin.

    + + +

    Examples of URLs

    +
        http://idaddress:6077/PlUtotV/DeFauLt/channel.m3u
    + will generate a list of channels for the PlutoTV:default instance while +
        http://idaddress:6077/M3U/channel.m3u
    + generates a list of channels for all instances of the M3U plugin, combined. + To get an EPG for everything under XUMO +
        http://idaddress:6077/Xumo/xmltv.xml
    + To get a M3U formatted channel list of M3U:Stirr +
        http://idaddress:6077/m3u/sTirR/playlist or
    +    http://idaddress:6077/m3U/sTirR/channels.m3u
    + To get EPG for all plugins in Cabernet +
        http://idaddress:6077/xmltv.xml
    + + For TVHeadend, it would be appropriate to create a network for each plugin or plugin:instance.
    + For Plex, you can create a TV Source and Guide Data for all or per plugin
    + For Emby and JellyFin, you can create a TV Source and Guide Data for all, per plugin, or plugin:instance. + + Setting up the HDHomeRun connection with Emby, Plex or JellyFin, you can use the following link
    + http://ipaddress:6077 or http://ipaddress:6077/PlutoTV
    + for plutotv only channels. When requested, use the full url to identify the xmltv.xml file + + diff --git a/lib/web/htdocs/images/1280px-Locast_logo.png b/lib/web/htdocs/images/1280px-Locast_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f997585bcd5a047763165e9b8b889119fa56f996 Binary files /dev/null and b/lib/web/htdocs/images/1280px-Locast_logo.png differ diff --git a/lib/web/htdocs/images/LICENSE b/lib/web/htdocs/images/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..73176c58aba65836e1779699369b9aa8c41c3939 --- /dev/null +++ b/lib/web/htdocs/images/LICENSE @@ -0,0 +1,4 @@ +Creative Commons Attribution 4.0 License (CC-BY) + +Images for this application are licensed under a Creative Commons Attribution 4.0 License (CC-BY) +To view a copy of this license, visit https://creativecommons.org/licenses/by/4.0/legalcode diff --git a/lib/web/htdocs/images/TVHL Logo.png b/lib/web/htdocs/images/TVHL Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d53fc973feb3702f11cc582c3c67effd55fa7738 Binary files /dev/null and b/lib/web/htdocs/images/TVHL Logo.png differ diff --git a/lib/web/htdocs/images/__init__.py b/lib/web/htdocs/images/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/images/favicon.ico b/lib/web/htdocs/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ce45dbbbbc06cb40469a77c77970f9a94bd5f05a Binary files /dev/null and b/lib/web/htdocs/images/favicon.ico differ diff --git a/lib/web/htdocs/images/favicon.png b/lib/web/htdocs/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..b63d0195e5e63400738abb1add2855023af48c16 Binary files /dev/null and b/lib/web/htdocs/images/favicon.png differ diff --git a/lib/web/htdocs/images/favicon2.png b/lib/web/htdocs/images/favicon2.png new file mode 100644 index 0000000000000000000000000000000000000000..0c73d3fcf5eaa136dce26ad4c06d53c01eae44cd Binary files /dev/null and b/lib/web/htdocs/images/favicon2.png differ diff --git a/lib/web/htdocs/images/grapes.xcf b/lib/web/htdocs/images/grapes.xcf new file mode 100644 index 0000000000000000000000000000000000000000..30696c7582f14b414c93613210faa0303bf4abb4 Binary files /dev/null and b/lib/web/htdocs/images/grapes.xcf differ diff --git a/lib/web/htdocs/images/icon-128x128.png b/lib/web/htdocs/images/icon-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..d5eaa679d4cb6874eb43296617cf250a8038c0f9 Binary files /dev/null and b/lib/web/htdocs/images/icon-128x128.png differ diff --git a/lib/web/htdocs/images/icon-144x144.png b/lib/web/htdocs/images/icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..84f0ce4bdc8971b1f7f1e10183c3d9c5d7b19d68 Binary files /dev/null and b/lib/web/htdocs/images/icon-144x144.png differ diff --git a/lib/web/htdocs/images/icon-152x152.png b/lib/web/htdocs/images/icon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..32e3842fcac9cec2fef5abf1701611f0f1bf0650 Binary files /dev/null and b/lib/web/htdocs/images/icon-152x152.png differ diff --git a/lib/web/htdocs/images/icon-180x180.png b/lib/web/htdocs/images/icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..cea0177fecf3ff49f4b39814cd9675610d04b511 Binary files /dev/null and b/lib/web/htdocs/images/icon-180x180.png differ diff --git a/lib/web/htdocs/images/icon-192x192.png b/lib/web/htdocs/images/icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..ddfc4345a3cca3a12c2c3d852ee8b57e930964ea Binary files /dev/null and b/lib/web/htdocs/images/icon-192x192.png differ diff --git a/lib/web/htdocs/images/icon-32x32.png b/lib/web/htdocs/images/icon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..b63d0195e5e63400738abb1add2855023af48c16 Binary files /dev/null and b/lib/web/htdocs/images/icon-32x32.png differ diff --git a/lib/web/htdocs/images/icon-384x384.png b/lib/web/htdocs/images/icon-384x384.png new file mode 100644 index 0000000000000000000000000000000000000000..be4dc4bffb9f28558a15e66054228e3cba1be813 Binary files /dev/null and b/lib/web/htdocs/images/icon-384x384.png differ diff --git a/lib/web/htdocs/images/icon-512x512.png b/lib/web/htdocs/images/icon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..d092ccc0ebc7f4a1bd9cf68eba88bdad74f7449a Binary files /dev/null and b/lib/web/htdocs/images/icon-512x512.png differ diff --git a/lib/web/htdocs/images/icon-72x72.png b/lib/web/htdocs/images/icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..7dee3ae157ffbe0f8a539c4fd6d368a880188a53 Binary files /dev/null and b/lib/web/htdocs/images/icon-72x72.png differ diff --git a/lib/web/htdocs/images/icon-96x96.png b/lib/web/htdocs/images/icon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..0c6562a698d76ff7d04e60bf8d76c496e4518401 Binary files /dev/null and b/lib/web/htdocs/images/icon-96x96.png differ diff --git a/lib/web/htdocs/images/locast_large.jpg b/lib/web/htdocs/images/locast_large.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bd045b66e1ee7e8da2aa9cd2acd75cf27f19a640 Binary files /dev/null and b/lib/web/htdocs/images/locast_large.jpg differ diff --git a/lib/web/htdocs/images/locast_large.png b/lib/web/htdocs/images/locast_large.png new file mode 100644 index 0000000000000000000000000000000000000000..2ad15af007e38982a989c81f9c8d052ee295711e Binary files /dev/null and b/lib/web/htdocs/images/locast_large.png differ diff --git a/lib/web/htdocs/images/locast_small.jpg b/lib/web/htdocs/images/locast_small.jpg new file mode 100644 index 0000000000000000000000000000000000000000..df17de71f7ed16cd29cf5449b77bc70d270d10cd Binary files /dev/null and b/lib/web/htdocs/images/locast_small.jpg differ diff --git a/lib/web/htdocs/images/locast_small.png b/lib/web/htdocs/images/locast_small.png new file mode 100644 index 0000000000000000000000000000000000000000..716ccc4afde7f67d175b9891f0b89bb9af98e282 Binary files /dev/null and b/lib/web/htdocs/images/locast_small.png differ diff --git a/lib/web/htdocs/images/logowhite.xcf b/lib/web/htdocs/images/logowhite.xcf new file mode 100644 index 0000000000000000000000000000000000000000..1ed833f69d1aaa1b7997f30fe6a2e2e785ff0cdc Binary files /dev/null and b/lib/web/htdocs/images/logowhite.xcf differ diff --git a/lib/web/htdocs/modules/__init__.py b/lib/web/htdocs/modules/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/backdrop/__init__.py b/lib/web/htdocs/modules/backdrop/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/backdrop/backdrop.js b/lib/web/htdocs/modules/backdrop/backdrop.js new file mode 100644 index 0000000000000000000000000000000000000000..32d20d03576631a9337c5bf503db11e3b7186dfc --- /dev/null +++ b/lib/web/htdocs/modules/backdrop/backdrop.js @@ -0,0 +1 @@ +define(["browser","connectionManager","playbackManager","dom","Modernizr","css!./style"],function(browser,connectionManager,playbackManager,dom,Modernizr){"use strict";function enableAnimation(){return Modernizr.cssanimations&&dom.supportsEventListenerOnce()}function enableRotation(){return!!enableAnimation()}function Backdrop(){}var backdropContainer,hasExternalBackdrop,currentLoadingBackdrop,backgroundContainer,hasInternalBackdrop;function getBackdropContainer(){return(backdropContainer=backdropContainer||document.querySelector(".backdropContainer"))||((backdropContainer=document.createElement("div")).classList.add("backdropContainer"),document.body.insertBefore(backdropContainer,document.body.firstChild)),backdropContainer}function clearBackdrop(clearAll){clearRotation(),currentLoadingBackdrop&&(currentLoadingBackdrop.destroy(),currentLoadingBackdrop=null),getBackdropContainer().innerHTML="",clearAll&&(hasExternalBackdrop=!1),internalBackdrop(!1)}function getBackgroundContainer(){return backgroundContainer=backgroundContainer||document.querySelector(".backgroundContainer")}function setBackgroundContainerBackgroundEnabled(){hasInternalBackdrop||hasExternalBackdrop?getBackgroundContainer().classList.add("withBackdrop"):getBackgroundContainer().classList.remove("withBackdrop")}function internalBackdrop(enabled){hasInternalBackdrop=enabled,setBackgroundContainerBackgroundEnabled()}function setBackdropImage(url,animate){currentLoadingBackdrop&&(currentLoadingBackdrop.destroy(),currentLoadingBackdrop=null);var elem=getBackdropContainer(),existingBackdropImage=elem.querySelector(".displayingBackdropImage");if(existingBackdropImage){if(existingBackdropImage.getAttribute("data-url")===url)return;existingBackdropImage.classList.remove("displayingBackdropImage")}var instance=new Backdrop;instance.load(url,animate,elem,existingBackdropImage),currentLoadingBackdrop=instance}Backdrop.prototype.load=function(url,animate,parent,existingBackdropImage){var img=new Image,self=this;this.previousBackdropImage=existingBackdropImage,img.onload=function(){var backdropImage,onAnimationComplete;self.isDestroyed?self.removePreviousBackdropImage():((backdropImage=document.createElement("div")).classList.add("backdropImage","displayingBackdropImage"),backdropImage.style.backgroundImage="url('"+url+"')",backdropImage.setAttribute("data-url",url),backdropImage.setAttribute("loading","lazy"),self.elem=backdropImage,internalBackdrop(!0),animate&&enableAnimation()&&backdropImage.classList.add("backdropImageFadeIn"),parent.appendChild(backdropImage),enableAnimation()?(onAnimationComplete=function(){dom.removeEventListener(backdropImage,dom.whichAnimationEvent(),onAnimationComplete,{once:!0}),dom.removeEventListener(backdropImage,dom.whichAnimationCancelEvent(),onAnimationComplete,{once:!0}),self.removePreviousBackdropImage()},dom.addEventListener(backdropImage,dom.whichAnimationEvent(),onAnimationComplete,{once:!0}),dom.addEventListener(backdropImage,dom.whichAnimationCancelEvent(),onAnimationComplete,{once:!0})):self.removePreviousBackdropImage())},img.src=url,this.url=url},Backdrop.prototype.removePreviousBackdropImage=function(){var existingBackdropImage=this.previousBackdropImage;existingBackdropImage&&existingBackdropImage.parentNode&&existingBackdropImage.parentNode.removeChild(existingBackdropImage)},Backdrop.prototype.cancelAnimation=function(){var elem=this.elem;elem&&(elem.classList.remove("backdropImageFadeIn"),this.elem=null)},Backdrop.prototype.destroy=function(){this.isDestroyed=!0,this.cancelAnimation(),this.removePreviousBackdropImage()};var rotationInterval,standardWidths=[480,720,1280,1440,1920];function getBackdropMaxWidth(){var width=dom.getWindowSize().innerWidth;if(standardWidths.includes(width))return width;width=100*Math.floor(width/100);return Math.min(width,1920)}function getImageUrls(items,imageOptions){for(var list=[],i=0,length=items.length;i=currentRotatingImages.length&&(newIndex=0),setBackdropImage(currentRotatingImages[currentRotationIndex=newIndex],animationEnabledByCaller),animationEnabledByCaller=!0)}function clearRotation(){rotationInterval&&clearInterval(rotationInterval),rotationInterval=null,currentRotatingImages=[],currentRotationIndex=-1}return{getImageUrls:getImageUrls,setBackdrops:function(items,imageOptions,enableImageRotation,enableAnimation){var images=getImageUrls(items,imageOptions);images.length?function(images,enableImageRotation,enableAnimation){if(function(a,b){if(a===b)return 1;if(null!=a&&null!=b&&a.length===b.length){for(var i=0;i 0 ) { + $('table.sortable th:nth-child(1)').css({"background": "rgba(155,255,155,0.3)"}); + } else { + $('table.sortable th:nth-child(1)').css({"background": ""}); + } + } + + var cbTextfilterCheckboxClicked = function(elemClicked, id, name) { + $("table.sortable th label:contains('"+name+"')").each(function() { + index = $(this).parent().index()+1; + }); + if ( !elemClicked.is(':checked') ) { + $('div[id='+id+'] input[type=text]').val(''); + $('table.sortable th:nth-child('+index+')').css({"font-style": "inherit", "background": ""}); + $('table.sortable td:nth-child('+index+')').each(function() { + $(this).parent().removeClass(name+"-hide"); + }); + } + } + + var cbTextfilterTextKeyInput = function(elemClicked, name, id) { + var keyValue = elemClicked.val(); + var index; + $("table.sortable th label:contains('"+name+"')").each(function() { + index = $(this).parent().index()+1; + }); + if ( keyValue == "" ) { + $('div[id='+id+'] input[type=checkbox]').prop('checked', false); + $('table.sortable td:nth-child('+index+')').each(function() { + $(this).parent().removeClass(name+"-hide"); + }); + $('table.sortable th:nth-child('+index+')').css({"font-style": "inherit", "background": ""}); + } else { + $('div[id='+id+'] input[type=checkbox]').prop('checked', true); + $('table.sortable td:nth-child('+index+')').each(function() { + if ( $(this).find('input').length > 0 ) { + if ( keyValue.toLowerCase() == "blank" ) { + if ( !$(this).find('input').val() ) { + $(this).parent().removeClass(name+"-hide"); + } else { + $(this).parent().addClass(name+"-hide"); + } + } else { + if ( $(this).find('input').val().toLowerCase().includes(keyValue.toLowerCase()) ) { + $(this).parent().removeClass(name+"-hide"); + } else { + $(this).parent().addClass(name+"-hide"); + } + } + } else { + if ( $(this).text().toLowerCase().includes(keyValue.toLowerCase()) ) { + $(this).parent().removeClass(name+"-hide"); + } else { + $(this).parent().addClass(name+"-hide"); + } + } + }); + $('table.sortable th:nth-child('+index+')').css( + {"font-style": "italic", "background": "rgba(155,255,155,0.3)"}); + } + } + + var resetFilters = function(arg1) { + $('div.xmenu input[type=checkbox]').each(function() { + var id = $(this).parent().parent().parent().prop('id'); + var name = id.replace('-menu', ''); + inputName = $(this).attr('name'); + ckbxStatus = $(this).is(':checked'); + if ( inputName.endsWith('-mi') ) { + if (!ckbxStatus) { + cbEnabledCheckboxClicked($(this)); + } + } else { + if (ckbxStatus) { + cbTextfilterTextKeyInput($(this).parent().find('input[type=text]'), name, id); + } + } + }); + } + resetFilters(); + +}, 500)); diff --git a/lib/web/htdocs/modules/common/__init__.py b/lib/web/htdocs/modules/common/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/common/inputmanager.js b/lib/web/htdocs/modules/common/inputmanager.js new file mode 100644 index 0000000000000000000000000000000000000000..0ebbee60cb98e8f37c44b4e2f5572884030e9a07 --- /dev/null +++ b/lib/web/htdocs/modules/common/inputmanager.js @@ -0,0 +1,151 @@ +(function (playbackManager, focusManager, appRouter, dom) { + "use strict"; + var lastInputTime = Date.now(); + function notify() { + (lastInputTime = Date.now()), handleCommand("unknown"); + } + var commandTimes = {}; + function handleCommand(name, options) { + lastInputTime = Date.now(); + var tagName, + sourceElement = options ? options.sourceElement : null; + ((sourceElement = sourceElement || document.activeElement) && "BODY" !== (tagName = sourceElement.tagName) && "HTML" !== tagName) || (sourceElement = focusManager.getCurrentScope()); + var command, + last, + now, + customEvent = new CustomEvent("command", { detail: { command: name }, bubbles: !0, cancelable: !0 }); + if (!sourceElement.dispatchEvent(customEvent)) return !0; + switch (name) { + case "up": + return focusManager.moveUp(sourceElement), !0; + case "down": + return focusManager.moveDown(sourceElement), !0; + case "left": + return focusManager.moveLeft(sourceElement), !0; + case "right": + return focusManager.moveRight(sourceElement), !0; + case "home": + return appRouter.goHome(), !0; + case "settings": + return appRouter.showSettings(), !0; + case "back": + return appRouter.back(), !0; + case "forward": + return !0; + case "select": + return sourceElement.click(), !0; + case "menu": + case "info": + return !0; + case "nextchapter": + return playbackManager.nextChapter(), !0; + case "next": + case "nexttrack": + return playbackManager.nextTrack(), !0; + case "previous": + case "previoustrack": + return playbackManager.previousTrack(), !0; + case "previouschapter": + return playbackManager.previousChapter(), !0; + case "guide": + return appRouter.showGuide(), !0; + case "recordedtv": + return appRouter.showRecordedTV(), !0; + case "record": + return !0; + case "livetv": + return appRouter.showLiveTV(), !0; + case "mute": + return playbackManager.setMute(!0), !0; + case "unmute": + return playbackManager.setMute(!1), !0; + case "togglemute": + return playbackManager.toggleMute(), !0; + case "channelup": + return playbackManager.channelUp(), !0; + case "channeldown": + return playbackManager.channelDown(), !0; + case "volumedown": + return playbackManager.volumeDown(), !0; + case "volumeup": + return playbackManager.volumeUp(), !0; + case "play": + return playbackManager.unpause(), !0; + case "pause": + return playbackManager.pause(), !0; + case "playpause": + return playbackManager.playPause(), !0; + case "stop": + return (last = commandTimes[(command = "stop")] || 0), (now = Date.now()) - last < 1e3 || ((commandTimes[command] = now), !1) || playbackManager.stop(), !0; + case "changezoom": + return playbackManager.toggleAspectRatio(), !0; + case "changeaudiotrack": + return playbackManager.changeAudioStream(), !0; + case "changesubtitletrack": + return playbackManager.changeSubtitleStream(), !0; + case "search": + return appRouter.showSearch(), !0; + case "favorites": + return appRouter.showFavorites(), !0; + case "fastforward": + return playbackManager.fastForward(), !0; + case "rewind": + return playbackManager.rewind(), !0; + case "togglefullscreen": + return playbackManager.toggleFullscreen(), !0; + case "disabledisplaymirror": + return playbackManager.enableDisplayMirroring(!1), !0; + case "enabledisplaymirror": + return playbackManager.enableDisplayMirroring(!0), !0; + case "toggledisplaymirror": + return playbackManager.toggleDisplayMirroring(), !0; + case "togglestats": + return !0; + case "movies": + case "music": + case "tv": + return appRouter.goHome(), !0; + case "nowplaying": + return appRouter.showNowPlaying(), !0; + case "save": + case "screensaver": + case "refresh": + case "changebrightness": + case "red": + case "green": + case "yellow": + case "blue": + case "grey": + case "brown": + return !0; + case "repeatnone": + return playbackManager.setRepeatMode("RepeatNone"), !0; + case "repeatall": + return playbackManager.setRepeatMode("RepeatAll"), !0; + case "repeatone": + return playbackManager.setRepeatMode("RepeatOne"), !0; + default: + return !1; + } + } + return ( + dom.addEventListener(document, "click", notify, { passive: !0 }), + { + trigger: handleCommand, + handle: handleCommand, + notify: notify, + notifyMouseMove: function () { + lastInputTime = Date.now(); + }, + idleTime: function () { + return Date.now() - lastInputTime; + }, + on: function (scope, fn) { + dom.addEventListener(scope, "command", fn, {}); + }, + off: function (scope, fn) { + dom.removeEventListener(scope, "command", fn, {}); + }, + } + ); +}); diff --git a/lib/web/htdocs/modules/dashboard/__init__.py b/lib/web/htdocs/modules/dashboard/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/dashboard/dashboard.css b/lib/web/htdocs/modules/dashboard/dashboard.css new file mode 100644 index 0000000000000000000000000000000000000000..62b3e347267f354017ed6a27194fe3d72c8daaad --- /dev/null +++ b/lib/web/htdocs/modules/dashboard/dashboard.css @@ -0,0 +1,5 @@ +td { + text-align: center; + padding-left: 1em; + padding-right: 1em; +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/dashboard/dashboard.js b/lib/web/htdocs/modules/dashboard/dashboard.js new file mode 100644 index 0000000000000000000000000000000000000000..576fcd64982fd40bbdbc69b0c2891c37257cc204 --- /dev/null +++ b/lib/web/htdocs/modules/dashboard/dashboard.js @@ -0,0 +1,79 @@ +$(document).ready(setTimeout(function(){ + var tunerstatus = {} + var schedstatus = [] + var expire = 0 + + function getDashboardStatus(){ + return $.getJSON("/api/dashstatus.json").then(function(json){ + return json; + }); + } + + function populateDashboard() { + getDashboardStatus().then(function(json_dashboard) { + tunerstatus = json_dashboard['tunerstatus'] + schedstatus = json_dashboard['schedstatus'] + tuner_active = populateTuner(tunerstatus); + sched_active = populateSchedule(schedstatus); + if ( tuner_active || sched_active ) { + expire = 1000; + } else if (expire < 30000) { + expire = expire + 3000; + } + setTimeout(function(){ + if($("#dashboard").length !== 0) { + populateDashboard(); + } + }, expire); + }); + } + + function populateTuner(tuner_data) { + $('#dashboard').html('

    Tuner Status

    '); + $('#tuners').append('State' + + 'Plugin' + + 'Tuner' + + 'Instance' + + 'Channel' + + 'Clients' + ); + var active = false; + if ( tuner_data === null ) { + $('#tuners').append('Tuner Status is Down, check 5004 process'); + } else { + $.each(tuner_data, function(key1, list_value) { + if(list_value !== null) { + if (typeof list_value === 'object' ) { + $.each(list_value, function(key2, tuner_status) { + if (typeof tuner_status === 'object' ) { + $('#tuners').append('' + tuner_status.status +'' + key1 + 'tuner' + key2 + '' + tuner_status.instance + '' + tuner_status.ch + '' + tuner_status.mux + ''); + active = true; + console.log(tuner_status); + } + }); + } + } + }); + } + return active; + } + + function populateSchedule(sched_data) { + $('#dashboard').append('

    Scheduler Status

    '); + $('#sched').append('State' + + 'Area' + + 'Title' + + 'Plugin' + + 'Instance' + ); + var active = false; + $.each(sched_data, function(key1, dict_value) { + if(dict_value !== null) { + $('#sched').append('Running' + dict_value.area + '' + dict_value.title + '' + dict_value.namespace + '' + dict_value.instance + ''); + active = true + } + }); + return active; + } + populateDashboard(); +}, 1000)); diff --git a/lib/web/htdocs/modules/datamgmt/__init__.py b/lib/web/htdocs/modules/datamgmt/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/datamgmt/datamgmt.css b/lib/web/htdocs/modules/datamgmt/datamgmt.css new file mode 100644 index 0000000000000000000000000000000000000000..92531513fc386141d1a703f8ef38e8a6a6a3d820 --- /dev/null +++ b/lib/web/htdocs/modules/datamgmt/datamgmt.css @@ -0,0 +1,51 @@ +td a { + color: inherit; + text-decoration: none; +} +.dmTable{ + background: var(--docked-drawer-background); + width: 95%; +} +.dmIcon{ + min-width: 2em; + font-size: 150%; + text-align: center; + display: table-cell; +} + +@media not all and (min-width:50em){ + .dmIcon{ + min-width:1.3em; + } + .dmTable{ + width: 99%; + } +} +@media not all and (max-width:60em){ + .dmIcon{ + min-width:3em; + } +} +.dmItem{ + width:95%; +} +.dmItemTitle{ + font-size: 110%; + font-weight: bold; +} +.dmSection{ + font-size: 130%; + font-weight: bold; + width:95%; + +} +.dmButton{ + white-space: nowrap; + height: 2em; + width: 50%; +} +@media not all and (min-width:50em){ + .dmButton{ + min-width:15em; + } +} diff --git a/lib/web/htdocs/modules/datamgmt/datamgmt.js b/lib/web/htdocs/modules/datamgmt/datamgmt.js new file mode 100644 index 0000000000000000000000000000000000000000..d7ede231320fb496fbfc92969abb3ec3497fb61c --- /dev/null +++ b/lib/web/htdocs/modules/datamgmt/datamgmt.js @@ -0,0 +1,114 @@ +$(document).ready(function(){ + $('form').submit(function() { // catch the form's submit + $(this).find('input[type="checkbox"]').each(function() { + if ($(this).is(":checked") == true) { + var n = '#'+$(this).attr("id")+'hidden'; + $(n).prop("disabled", true); + } else { + var n = '#'+$(this).attr("id")+'hidden'; + $(n).prop("disabled", false); + } + }); + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), // GET or POST + url: $(this).attr('action'), + success: function(response) { // on success + $('#reset_status').html(response); + } + }); + return false; // cancel original submit event + }); +}); + +function load_dm_url(url) { + $("#content").load(url); + return false; +} +function load_backup_url(url) { + $("#dmbackup").load(url); + return false; +} +function display_tasks() { + $('div#schedtasks').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('div#schedtask').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); +} +function onChangeTimeType(timetype) { + if (timetype.value == 'weekly') { + $('#divDOW').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divTOD').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divINTL').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divRND').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + } else if (timetype.value == 'daily') { + $('#divDOW').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divTOD').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divINTL').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divRND').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + } else if (timetype.value == 'interval') { + $('#divINTL').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divRND').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divDOW').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divTOD').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + } else if (timetype.value == 'startup') { + $('#divINTL').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divRND').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divDOW').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divTOD').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + } + +} + diff --git a/lib/web/htdocs/modules/datamgmt/restore_backup.js b/lib/web/htdocs/modules/datamgmt/restore_backup.js new file mode 100644 index 0000000000000000000000000000000000000000..25c779ba621c877f5c0704d8f55af1ad1cd83287 --- /dev/null +++ b/lib/web/htdocs/modules/datamgmt/restore_backup.js @@ -0,0 +1,22 @@ +$(document).ready(function(){ + $('form').submit(function() { // catch the form's submit + $(this).find('input[type="checkbox"]').each(function() { + if ($(this).is(":checked") == true) { + var n = '#'+$(this).attr("id")+'hidden'; + $(n).prop("disabled", true); + } else { + var n = '#'+$(this).attr("id")+'hidden'; + $(n).prop("disabled", false); + } + }); + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), // GET or POST + url: $(this).attr('action'), + success: function(response) { // on success + $('#status').html(response); + } + }); + return false; // cancel original submit event + }); +}); diff --git a/lib/web/htdocs/modules/datamgmt/trigger.js b/lib/web/htdocs/modules/datamgmt/trigger.js new file mode 100644 index 0000000000000000000000000000000000000000..7d1b2c6276429584dae1a5244874062db8b03633 --- /dev/null +++ b/lib/web/htdocs/modules/datamgmt/trigger.js @@ -0,0 +1,13 @@ +$(document).ready(setTimeout(function(){ + $('form').submit(function() { + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), // GET or POST + url: $(this).attr('action'), + success: function(response) { // on success + $('#status').html(response); + } + }); + return false; + }); +}, 100)); \ No newline at end of file diff --git a/lib/web/htdocs/modules/emby-elements/__init__.py b/lib/web/htdocs/modules/emby-elements/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/emby-elements/emby-button/__init__.py b/lib/web/htdocs/modules/emby-elements/emby-button/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/emby-elements/emby-button/emby-button.css b/lib/web/htdocs/modules/emby-elements/emby-button/emby-button.css new file mode 100644 index 0000000000000000000000000000000000000000..79a1f6995f50018f4c46cba3274f865d635f8ce8 --- /dev/null +++ b/lib/web/htdocs/modules/emby-elements/emby-button/emby-button.css @@ -0,0 +1,261 @@ +.emby-button { + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: inline-flex; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin: 0 0.29em; + text-align: center; + font-size: inherit; + font-family: inherit; + color: inherit; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; + z-index: 0; + padding: 0.66em 1em; + vertical-align: middle; + border: 0; + vertical-align: middle; + -webkit-border-radius: 0.42em; + border-radius: 0.42em; + position: relative; + font-weight: 500; + -webkit-tap-highlight-color: transparent; + text-decoration: none; + line-height: inherit; +} +.emby-button-tv { + outline: 0 !important; +} +@media (pointer: coarse) { + .emby-button { + outline: 0 !important; + } +} +.emby-button:not(:focus-visible) { + outline: 0 !important; +} +.emby-button:not(:-moz-focusring) { + outline: 0 !important; +} +.emby-button:active, +.emby-button:hover { + outline: 0 !important; +} +.emby-button::-moz-focus-inner { + border: 0; +} +.button-flat { + background: 0 0; +} +.button-link { + background: 0 0; + margin: 0; + padding: 0; + vertical-align: initial; + font-weight: 400; + outline: 0 !important; +} +.button-link:not(.emby-button-tv):focus { + text-decoration: underline; +} +@media (pointer: coarse) { + .button-link:focus { + text-decoration: none; + } +} +.button-link:not(.emby-button-tv):active { + text-decoration: underline; +} +.button-link:focus:not(:focus-visible) { + text-decoration: none; +} +.button-link:focus:not(:-moz-focusring) { + text-decoration: none; +} +.button-inherit-color { + color: inherit !important; +} +.raised-mini { + padding-top: 0.4em; + padding-bottom: 0.4em; + -webkit-border-radius: 100em; + border-radius: 100em; +} +@media (hover: hover) and (pointer: fine) { + .button-flat:hover { + opacity: 0.5; + } + .button-link:hover { + text-decoration: underline; + } +} +.emby-button-focusscale { + -webkit-transition: -webkit-transform 180ms ease-in-out !important; + -o-transition: transform 180ms ease-in-out !important; + transition: transform 180ms ease-in-out !important; + -webkit-transform-origin: center center; + transform-origin: center center; +} +.emby-button-focusscale:focus { + -webkit-transform: scale(1.16); + transform: scale(1.16); + z-index: 1; +} +.button-icon { + font-size: 1.36em; +} +.button-icon-left { + margin-right: 0.25em; + margin-left: -0.25em; +} +.button-icon-left-centered { + margin-right: 0.5em; +} +.button-icon-right { + margin-left: 0.25em; + margin-right: -0.25em; +} +.fab { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: inline-flex; + -webkit-border-radius: 50%; + border-radius: 50%; + padding: 0.6em; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + justify-content: center; + text-align: center; +} +.emby-button.block { + display: block; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + justify-content: center; + margin: 0.25em 0; + width: 100%; +} +.paper-icon-button-light { + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: inline-flex; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin: 0 0.29em; + background: 0 0; + text-align: center; + font-size: inherit; + font-family: inherit; + color: inherit; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; + z-index: 0; + min-width: initial; + min-height: initial; + width: auto; + height: auto; + padding: 0.556em; + vertical-align: middle; + border: 0; + vertical-align: middle; + outline: 0 !important; + position: relative; + overflow: hidden; + -webkit-border-radius: 50%; + border-radius: 50%; + -webkit-tap-highlight-color: transparent; + -webkit-box-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-transition: background 0.4s ease-in-out; + -o-transition: background 0.4s ease-in-out; + transition: background 0.4s ease-in-out; +} +.paper-icon-button-light:active { + -webkit-transition: background 0.1s ease-in-out; + -o-transition: background 0.1s ease-in-out; + transition: background 0.1s ease-in-out; +} +.paper-icon-button-light::-moz-focus-inner { + border: 0; +} +.paper-icon-button-light[disabled] { + opacity: 0.3; +} +.paper-icon-button-light > i { + font-size: 1.66956521739130434em; + position: relative; + z-index: 1; + vertical-align: middle; +} +.paper-icon-button-img { + width: 1.72em; + max-height: 100%; + position: relative; + z-index: 1; + vertical-align: middle; +} +.icon-button-focusscale { + -webkit-transition: -webkit-transform 180ms ease-in-out !important; + -o-transition: transform 180ms ease-in-out !important; + transition: transform 180ms ease-in-out !important; + -webkit-transform-origin: center center; + transform-origin: center center; +} +.icon-button-focusscale:focus { + -webkit-transform: scale(1.3); + transform: scale(1.3); + z-index: 1; +} +.btnFilterWithBubble { + position: relative; +} +.filterButtonBubble { + color: #fff; + position: absolute; + background: #444; + top: 0; + right: 0; + width: 1.6em; + height: 1.6em; + z-index: 100000000; + display: -webkit-box; + display: -webkit-flex; + display: flex; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + justify-content: center; + font-size: 82%; + -webkit-border-radius: 100em; + border-radius: 100em; + -webkit-box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14), 0 1px 10px 0 rgba(0, 0, 0, 0.12), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + background: #03a9f4; + font-weight: 700; +} diff --git a/lib/web/htdocs/modules/emby-elements/emby-collapse/__init__.py b/lib/web/htdocs/modules/emby-elements/emby-collapse/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/emby-elements/emby-collapse/emby-collapse.css b/lib/web/htdocs/modules/emby-elements/emby-collapse/emby-collapse.css new file mode 100644 index 0000000000000000000000000000000000000000..fcbdbac1b8f8496250833ae4f311017015f9a921 --- /dev/null +++ b/lib/web/htdocs/modules/emby-elements/emby-collapse/emby-collapse.css @@ -0,0 +1,56 @@ +.emby-collapse { + margin: 0.5em 0; +} +.collapseContent { + border-width: 0; + padding: 1.25em 1.25em; + height: 0; + -webkit-transition: height 0.3s ease-in-out; + -o-transition: height 0.3s ease-in-out; + transition: height 0.3s ease-in-out; + overflow: hidden; +} +.emby-collapsible-button { + margin: 0; + display: -webkit-box; + display: -webkit-flex; + display: flex; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + text-transform: none; + width: 100%; + text-align: left; + text-transform: none; + border-width: 0; + padding-left: 0.1em; + background: 0 0; + -webkit-box-shadow: none; + box-shadow: none; + -webkit-border-radius: 0 !important; + border-radius: 0 !important; +} +.emby-collapsible-button-collapsed { + border-width: 0 0 0.1em 0 !important; + border-style: solid !important; +} +.emby-collapse-expandIcon { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-transition: -webkit-transform 180ms ease-in-out; + -o-transition: transform 180ms ease-in-out; + transition: transform 180ms ease-in-out; + position: absolute; + right: 0.5em; + font-size: 1.66956521739130434em; + -webkit-transform: rotate(270deg); + transform: rotate(270deg); +} +.emby-collapse-expandIconExpanded { + -webkit-transform: none; + transform: none; +} +.emby-collapsible-title { + margin: 0; + padding: 0; +} diff --git a/lib/web/htdocs/modules/flexstyles.css b/lib/web/htdocs/modules/flexstyles.css new file mode 100644 index 0000000000000000000000000000000000000000..d36ac4666e83f856a9b8dcbc94b4d9f6cf3e45e5 --- /dev/null +++ b/lib/web/htdocs/modules/flexstyles.css @@ -0,0 +1,75 @@ +.flex { + display: -webkit-box; + display: -webkit-flex; + display: flex; +} +.inline-flex { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: inline-flex; +} +.flex-direction-column { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + flex-direction: column; +} +.flex-direction-row { + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -webkit-flex-direction: row; + flex-direction: row; +} +.flex-direction-row-reverse { + -webkit-box-orient: horizontal; + -webkit-box-direction: reverse; + -webkit-flex-direction: row-reverse; + flex-direction: row-reverse; +} +.flex-grow { + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + flex-grow: 1; +} +.flex-shrink-zero { + -webkit-flex-shrink: 0; + flex-shrink: 0; +} +.align-items-center { + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; +} +.align-items-baseline { + -webkit-box-align: baseline; + -webkit-align-items: baseline; + align-items: baseline; +} +.align-items-flex-start { + -webkit-box-align: start; + -webkit-align-items: flex-start; + align-items: flex-start; +} +.align-items-flex-end { + -webkit-box-align: end; + -webkit-align-items: flex-end; + align-items: flex-end; +} +.justify-content-center { + -webkit-box-pack: center; + -webkit-justify-content: center; + justify-content: center; +} +.justify-content-flex-end { + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + justify-content: flex-end; +} +.flex-wrap-wrap { + -webkit-flex-wrap: wrap; + flex-wrap: wrap; +} +.align-self-flex-end { + -webkit-align-self: flex-end; + align-self: flex-end; +} diff --git a/lib/web/htdocs/modules/fonts/__init__.py b/lib/web/htdocs/modules/fonts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/fonts/fonts.css b/lib/web/htdocs/modules/fonts/fonts.css new file mode 100644 index 0000000000000000000000000000000000000000..fe0b1c02bb5b9452fdaa27d5b46983a74cacd74b --- /dev/null +++ b/lib/web/htdocs/modules/fonts/fonts.css @@ -0,0 +1,181 @@ +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 400; + src: local("Roboto"), local("Roboto-Regular"), url(roboto/KFOmCnqEu92Fr1Mu72xKOzY.woff2) format("woff2"); + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 400; + src: local("Roboto"), local("Roboto-Regular"), url(roboto/KFOmCnqEu92Fr1Mu5mxKOzY.woff2) format("woff2"); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 400; + src: local("Roboto"), local("Roboto-Regular"), url(roboto/KFOmCnqEu92Fr1Mu7mxKOzY.woff2) format("woff2"); + unicode-range: U+1F00-1FFF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 400; + src: local("Roboto"), local("Roboto-Regular"), url(roboto/KFOmCnqEu92Fr1Mu4WxKOzY.woff2) format("woff2"); + unicode-range: U+0370-03FF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 400; + src: local("Roboto"), local("Roboto-Regular"), url(roboto/KFOmCnqEu92Fr1Mu7WxKOzY.woff2) format("woff2"); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 400; + src: local("Roboto"), local("Roboto-Regular"), url(roboto/KFOmCnqEu92Fr1Mu7GxKOzY.woff2) format("woff2"); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 400; + src: local("Roboto"), local("Roboto-Regular"), url(roboto/KFOmCnqEu92Fr1Mu4mxK.woff2) format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 500; + src: local("Roboto Medium"), local("Roboto-Medium"), url(roboto/KFOlCnqEu92Fr1MmEU9fCRc4EsA.woff2) format("woff2"); + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 500; + src: local("Roboto Medium"), local("Roboto-Medium"), url(roboto/KFOlCnqEu92Fr1MmEU9fABc4EsA.woff2) format("woff2"); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 500; + src: local("Roboto Medium"), local("Roboto-Medium"), url(roboto/KFOlCnqEu92Fr1MmEU9fCBc4EsA.woff2) format("woff2"); + unicode-range: U+1F00-1FFF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 500; + src: local("Roboto Medium"), local("Roboto-Medium"), url(roboto/KFOlCnqEu92Fr1MmEU9fBxc4EsA.woff2) format("woff2"); + unicode-range: U+0370-03FF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 500; + src: local("Roboto Medium"), local("Roboto-Medium"), url(roboto/KFOlCnqEu92Fr1MmEU9fCxc4EsA.woff2) format("woff2"); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 500; + src: local("Roboto Medium"), local("Roboto-Medium"), url(roboto/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2) format("woff2"); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 500; + src: local("Roboto Medium"), local("Roboto-Medium"), url(roboto/KFOlCnqEu92Fr1MmEU9fBBc4.woff2) format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 700; + src: local("Roboto Bold"), local("Roboto-Bold"), url(roboto/KFOlCnqEu92Fr1MmWUlfCRc4EsA.woff2) format("woff2"); + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 700; + src: local("Roboto Bold"), local("Roboto-Bold"), url(roboto/KFOlCnqEu92Fr1MmWUlfABc4EsA.woff2) format("woff2"); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 700; + src: local("Roboto Bold"), local("Roboto-Bold"), url(roboto/KFOlCnqEu92Fr1MmWUlfCBc4EsA.woff2) format("woff2"); + unicode-range: U+1F00-1FFF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 700; + src: local("Roboto Bold"), local("Roboto-Bold"), url(roboto/KFOlCnqEu92Fr1MmWUlfBxc4EsA.woff2) format("woff2"); + unicode-range: U+0370-03FF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 700; + src: local("Roboto Bold"), local("Roboto-Bold"), url(roboto/KFOlCnqEu92Fr1MmWUlfCxc4EsA.woff2) format("woff2"); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 700; + src: local("Roboto Bold"), local("Roboto-Bold"), url(roboto/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2) format("woff2"); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: Roboto; + font-style: normal; + font-weight: 700; + src: local("Roboto Bold"), local("Roboto-Bold"), url(roboto/KFOlCnqEu92Fr1MmWUlfBBc4.woff2) format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +html { + font-family: Roboto, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, system-ui, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Open Sans", sans-serif; +} +html { + font-size: 90.15%; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; +} +.html-ios { + font-size: 83.87%; +} +.html-osx { + font-size: 92%; +} +h1 { + font-weight: 500; + font-size: 2em; +} +h2 { + font-weight: 700; + font-size: 1.5em; + margin-top: 1em; + margin-bottom: 1em; +} +h3 { + font-weight: 700; + font-size: 1.17em; +} +.layout-tv { + font-size: 2.5vh; +} diff --git a/lib/web/htdocs/modules/fonts/material-icons/LDItaoyNOAY6Uewc665JcIzCKsKc_M9flwmJ_1.woff b/lib/web/htdocs/modules/fonts/material-icons/LDItaoyNOAY6Uewc665JcIzCKsKc_M9flwmJ_1.woff new file mode 100644 index 0000000000000000000000000000000000000000..96c92dc177a41cacaeb6cfb629a1865fc2e1f3c2 --- /dev/null +++ b/lib/web/htdocs/modules/fonts/material-icons/LDItaoyNOAY6Uewc665JcIzCKsKc_M9flwmJ_1.woff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46be183ce7d4d151622301a8722cab463bc4a206c287a7a60df33194f2925c36 +size 124360 diff --git a/lib/web/htdocs/modules/fonts/material-icons/__init__.py b/lib/web/htdocs/modules/fonts/material-icons/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2 b/lib/web/htdocs/modules/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c2f57e7ae1251db8f1492909a9e04dd2124f4b87 --- /dev/null +++ b/lib/web/htdocs/modules/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8265f64786397d6b832d1ca0aafdf149ad84e72759fffa9f7272e91a0fb015d1 +size 128352 diff --git a/lib/web/htdocs/modules/fonts/material-icons/style.css b/lib/web/htdocs/modules/fonts/material-icons/style.css new file mode 100644 index 0000000000000000000000000000000000000000..1486dc58da7c69a52fd9f64533af9887dfa0ba09 --- /dev/null +++ b/lib/web/htdocs/modules/fonts/material-icons/style.css @@ -0,0 +1,26 @@ +@font-face { + font-family: "Material Icons Round"; + font-style: normal; + font-weight: 400; + src: local("Material Icons Round"), local("MaterialIcons-Round"), url(flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2) format("woff2"); +} +.md-icon { + font-family: "Material Icons Round"; + font-weight: 400; + font-style: normal; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-feature-settings: "liga"; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-feature-settings: "liga"; + font-feature-settings: "liga"; + overflow: hidden; + vertical-align: middle; +} diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fABc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fABc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1db842264e4744ce94ac65074b435282f991ebb3 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fABc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fBBc4.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fBBc4.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6362d7f64a4d45d901e9f1399e020a476ffa8065 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fBBc4.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fBxc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fBxc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..86d474c7a585d91e1e7f3ed75d3d55fcdb62e773 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fBxc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCBc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCBc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..ee7331fb828b12f7e93f73b1840b5090fd37f6b5 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCBc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCRc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCRc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b4b5df792404d26a002c1e9decab98f5fce2824c Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCRc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..530e22cca5bcb69dc2f0a4dac1a144b6b46f931b Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCxc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCxc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5a458afa0b7e1b871d47f76291c9278dd454c253 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmEU9fCxc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfABc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfABc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..66797430321f5e9316f27951209de5cd9280d4ec Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfABc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfBBc4.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfBBc4.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..32b25eee7c5c3309ea53facaaf016256887ec0b2 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfBBc4.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfBxc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfBxc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e981633e276a926065e09531bfa4bb4a7fc6e0e6 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfBxc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCBc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCBc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1b2f0321a78a85cefa346d42ca6eb96592ff130f Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCBc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCRc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCRc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7445d92a5ee7f6fb15d4c9cb4437a70116f9b1db Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCRc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5d7fed57ffad12931859c29b5b67a988502ced8b Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCxc4EsA.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCxc4EsA.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..22aaad7c9a7b978ff01c0281a25102ba214e9def Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOlCnqEu92Fr1MmWUlfCxc4EsA.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu4WxKOzY.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu4WxKOzY.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..76fc366160eb2e26c77db025736534be511bc514 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu4WxKOzY.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu4mxK.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu4mxK.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1a537015109f44983c26738a436c8f5edc44b9ae Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu4mxK.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu5mxKOzY.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu5mxKOzY.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..de226b374ad4d078cab153d4252d24da1d430a2f Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu5mxKOzY.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu72xKOzY.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu72xKOzY.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e3eabe7e95fff46920025c7f570e57946192b21d Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu72xKOzY.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7GxKOzY.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7GxKOzY.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..de83f9cffc07c07d65d135e7c74cc9bf7fc49854 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7GxKOzY.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7WxKOzY.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7WxKOzY.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..165cbac8994ab77076a1ea9e3b5a7388b45d9617 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7WxKOzY.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7mxKOzY.woff2 b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7mxKOzY.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c49a20477eb9cb2b2a38eea1e931f22ee6813091 Binary files /dev/null and b/lib/web/htdocs/modules/fonts/roboto/KFOmCnqEu92Fr1Mu7mxKOzY.woff2 differ diff --git a/lib/web/htdocs/modules/fonts/roboto/__init__.py b/lib/web/htdocs/modules/fonts/roboto/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/fonts/subfont.woff2 b/lib/web/htdocs/modules/fonts/subfont.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e880879c8b46a3c03b4838995ad773a7cee49c9c --- /dev/null +++ b/lib/web/htdocs/modules/fonts/subfont.woff2 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bd02093e310012f63ea615b4272da7428952c9826506e651821624cffad4518 +size 5570248 diff --git a/lib/web/htdocs/modules/layout.css b/lib/web/htdocs/modules/layout.css new file mode 100644 index 0000000000000000000000000000000000000000..e1b7df6489bbc342f26e26fdc11152606b6ed649 --- /dev/null +++ b/lib/web/htdocs/modules/layout.css @@ -0,0 +1,67 @@ +:root { + --window-inset-top: 0px; + --window-inset-bottom: 0px; + --window-inset-left: 0px; + --window-inset-right: 0px; +} +html { + line-height: 1.618; +} +body, +html { + margin: 0 !important; + padding: 0 !important; + height: 100%; + overflow-x: hidden; + overflow-anchor: none; +} + +.noScrollY { + overflow-y: hidden; +} + +.backgroundContainer { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + contain: strict; +} + +.mainAnimatedPages { + position:fixed; + top:0; + left:0; + right:0; + bottom:0; + contain:layout style size; +} +.page { + position:absolute; + top:0; + left:0; + right:0; + bottom:0; + contain:layout style size; +} + +.backdropContainer-withfulldrawer, .skinBody-withFullDrawer { + left: 18.6em; +} + +@media all and (min-width: 20em) { + .backdropContainer-withfulldrawer, .skinBody-withFullDrawer { + left: 13em; + } +} +@media all and (min-width: 40em) { + .backdropContainer-withfulldrawer, .skinBody-withFullDrawer { + left: 30%; + } +} +@media all and (min-width: 55em) { + .backdropContainer-withfulldrawer, .skinBody-withFullDrawer { + left: 18.6em; + } +} diff --git a/lib/web/htdocs/modules/listview/__init__.py b/lib/web/htdocs/modules/listview/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/listview/listview.css b/lib/web/htdocs/modules/listview/listview.css new file mode 100644 index 0000000000000000000000000000000000000000..12e763782f183b20f34a92014c086098b87f8f67 --- /dev/null +++ b/lib/web/htdocs/modules/listview/listview.css @@ -0,0 +1,428 @@ +.menuIconButton{ + color: var(--theme-text-color); + background-color: var(--card-background); + border-radius: 8px; + padding-top: 3px; + padding-bottom: 3px; + padding-right: 8px; + padding-left: 8px; +} + +.menuSection{ + min-width: inherit; + font-size: 1.7em; + background: var(--card-background); + border: 3px inset var(--docked-drawer-background); + display: inline-flex; + vertical-align: top; +} + +.menuSection:hover{ + opacity: 0.5; +} + +.menuButton{ + padding: 8px 8px; + padding-right: 1em; + width: 100%; + text-align: left; + display: flex; + align-items: center; + position: relative; + background-color: inherit; + color: var(--theme-text-color); + border: 0; +} +.menuButton:hover { + background-color: var(--theme-button-hover-color); + cursor: pointer; +} +.menuItem:{ + position: relative; +} + +.menuList{ + margin: 0; + display: block; + padding: 0; + list-style-type: none; +} +.menuList + .menuList{ + border-top: 1px solid #ddd; +} +.menuPanel{ + display: flex; + flex-direction: column; +} +.menuCanvas{ + position: absolute; + padding-top: 13px; + padding-bottom: 13px; + top: 0px; + left:0px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background-color: var(--drawer-background); + opacity: 1.0; + border: var(--line-size) solid var(--line-background) +} + +.listItemHide{ + display: none; +} +.listItemShow{ + display: flex; +} + + + +.listItem{ + background:0 0; + border:0; + outline:0!important; + color:inherit; + vertical-align:middle; + font-family:inherit; + font-size:inherit; + margin:0; + display:-webkit-box; + display:-webkit-flex; + display:flex; + -webkit-box-align:center; + -webkit-align-items:center; + align-items:center; + text-align:left; + padding:.25em 0; + overflow:hidden; + -webkit-flex-shrink:0; + flex-shrink:0; + text-decoration:none; + line-height:inherit +} +.listItem.dragging-over{ + position:relative +} +.dragging-over-top:before{ + content:' '; + height:.1em; + width:100%; + position:absolute; + top:0; + z-index:99999; + -webkit-border-radius:100em; + border-radius:100em +} +.dragging-over-bottom:after{ + content:' '; + height:.1em; + width:100%; + position:absolute; + bottom:0; + z-index:99999; + -webkit-border-radius:100em; + border-radius:100em +} +.listItem-noverticalpadding{ + padding-top:0; + padding-bottom:0 +} +.listItemCursor{ + cursor:pointer +} +@media (pointer:coarse){ + .listItem-touchzoom-transition{ + -webkit-transition:-webkit-transform .1s ease-in-out; + -o-transition:transform .1s ease-in-out; + transition:transform .1s ease-in-out; + -webkit-transition-delay:80ms; + -o-transition-delay:80ms; + transition-delay:80ms + } + .listItem-touchzoom:active{ + -webkit-transform:scale(.94); + transform:scale(.94) + } +} +.listItem-largeImage{ + padding:1em 0 +} +.listItem-withContentWrapper{ + -webkit-box-orient:vertical; + -webkit-box-direction:normal; + -webkit-flex-direction:column; + flex-direction:column; + -webkit-box-align:start; + -webkit-align-items:flex-start; + align-items:flex-start +} +.listItem-content{ + display:-webkit-box; + display:-webkit-flex; + display:flex; + -webkit-box-align:center; + -webkit-align-items:center; + align-items:center; + width:100% +} +.listItem-indexnumberleft{ + margin-right:1em; + margin-left:.5em +} +.listItem-border{ + border-bottom-width:.08em; + border-bottom-style:solid +} +.paperListItem:last-child{ + border-bottom-style:none +} +.listItemAside,.listItemIcon,.listItemImageContainer{ + -webkit-flex-shrink:0; + flex-shrink:0 +} +.listItemButton{ + margin:0; + -webkit-flex-shrink:0; + flex-shrink:0; + contain:layout style +} +@media (pointer:coarse){ + .listItemContextMenuButton{ + display:none + } +} +.listItemBody{ + -webkit-box-flex:1; + -webkit-flex-grow:1; + flex-grow:1; + padding:.35em .75em; + overflow:hidden; + -o-text-overflow:ellipsis; + text-overflow:ellipsis; + -webkit-box-orient:vertical; + -webkit-box-direction:normal; + -webkit-flex-direction:column; + flex-direction:column; + vertical-align:middle; + -webkit-box-pack:center; + -webkit-justify-content:center; + justify-content:center; + text-align:left; + -webkit-box-align:initial; + -webkit-align-items:initial; + align-items:initial; + margin:0 +} +.listItemBody-noleftpadding{ + padding-left:0!important +} +.listItemBody-code{ + font-family:monospace +} +.listItemBody-noverticalpadding{ + padding-top:0; + padding-bottom:0 +} +.listItemBodyText{ + margin:0; + overflow:hidden; + -o-text-overflow:ellipsis; + text-overflow:ellipsis +} +.listItemBodyText-nowrap{ + white-space:nowrap +} +.listItemImageContainer{ + width:3.26em; + height:3.26em; + position:relative; + display:-webkit-box; + display:-webkit-flex; + display:flex; + -webkit-box-align:center; + -webkit-align-items:center; + align-items:center; + -webkit-box-pack:center; + -webkit-justify-content:center; + justify-content:center; + -webkit-border-radius:.3em; + border-radius:.3em +} +.listItemImage{ + -webkit-border-radius:.3em; + border-radius:.3em; + width:100%; + height:100%; + -o-object-fit:contain; + object-fit:contain +} +.listItemImage-round,.listItemImageContainer-round{ + -webkit-border-radius:100em; + border-radius:100em +} +.listItemImageContainer-large{ + width:20vw; + height:11.25vw; + margin-right:.35em +} +.listItemImageButton{ + margin:0; + color:rgba(255,255,255,.6); + font-size:1.5em; + background:0 0; + -webkit-transition:-webkit-transform .2s ease-in-out; + -o-transition:transform .2s ease-in-out; + transition:transform .2s ease-in-out; + display:-webkit-box; + display:-webkit-flex; + display:flex; + position:absolute; + top:50%; + left:50%; + margin-left:-2em; + margin-top:-2em +} +@media(hover:hover) and (pointer:fine){ + .listItemImageButton:hover{ + -webkit-transform:scale(1.2,1.2); + transform:scale(1.2,1.2) + } +} +.listItemImageButton-icon{ + background:rgba(0,0,0,.4); + border:.08em solid currentColor; + -webkit-border-radius:100em; + border-radius:100em; + display:-webkit-box; + display:-webkit-flex; + display:flex; + -webkit-box-pack:center; + -webkit-justify-content:center; + justify-content:center; + -webkit-box-align:center; + -webkit-align-items:center; + align-items:center; + padding:.21em +} +@media all and (max-width:64em){ + .listItemImageContainer-large{ + width:33.75vw; + height:18.984375vw + } + .listItemImageButton{ + font-size:1.02em!important + } +} +.listItemImageContainer-large-tv{ + width:30vw!important; + height:16.875vw!important +} +.listItemIcon{ + width:1em!important; + height:1em!important; + font-size:163%; + padding:0 .25em +} +.listItemImageContainer>.listItemIcon{ + margin:0 +} +.listViewDragHandle{ + touch-action:none; + padding:1em +} +.listItemProgressBar{ + position:absolute!important; + bottom:0; + left:0; + right:0 +} +.listItem:focus{ + -webkit-border-radius:.42em; + border-radius:.42em +} +.listItem:focus .secondary{ + color:inherit!important +} +.listItem-focusscale{ + -webkit-transition:-webkit-transform .2s ease-out; + -o-transition:transform .2s ease-out; + transition:transform .2s ease-out +} +.listItem-focusscale:focus{ + -webkit-transform:scale(1.025,1.025); + transform:scale(1.025,1.025) +} +.paperList{ + margin:.5em auto; + -webkit-border-radius:.3em; + border-radius:.3em +} +.paperList-clear{ + background-color:transparent!important +} +.paperList .listItemImageContainer{ + margin:0 .25em 0 1em +} +.listItemMediaInfo{ + display:none; + -webkit-box-align:center; + -webkit-align-items:center; + align-items:center; + margin-right:1em; + -webkit-flex-shrink:0; + flex-shrink:0 +} +.listGroupHeader-first{ + margin-top:0 +} +.listItemIndicators{ + right:.324em; + top:.324em; + position:absolute; + display:-webkit-box; + display:-webkit-flex; + display:flex; + -webkit-box-align:center; + -webkit-align-items:center; + align-items:center +} +.listItem,.listItemBody,.listItemMediaInfo{ + display:-webkit-box; + display:-webkit-flex; + display:flex; + contain:layout style +} +.listItem-bottomoverview{ + font-size:88%; + margin-top:.2em +} +@media not all and (min-width:50em){ + .listItem-overview{ + display:none!important + } +} +@media all and (min-width:50em){ + .listItem-bottomoverview{ + display:none!important + } +} +.listItemCheckboxContainer{ + width:auto!important +} +@media all and (max-width:75em){ + .listViewUserDataButtons{ + display:none!important + } +} +.listItemPlayedIndicator{ + font-size:1.6em +} +.listItem .mediaStreamAttribute{ + padding-left:2.3em +} +@media(pointer:coarse){ + .listItem-textActionButton{ + pointer-events:none!important + } +} diff --git a/lib/web/htdocs/modules/listview/listview.js b/lib/web/htdocs/modules/listview/listview.js new file mode 100644 index 0000000000000000000000000000000000000000..fd457b8ff05fa705868a81c2303c746301eb80f4 --- /dev/null +++ b/lib/web/htdocs/modules/listview/listview.js @@ -0,0 +1,155 @@ +define(["itemContextMenu", "dom", "cardBuilder", "itemShortcuts", "itemHelper", "mediaInfo", "indicators", "connectionManager", "layoutManager", "globalize", "datetime", "apphost", "imageLoader", "focusManager", "css!./listview", "emby-ratingbutton", "emby-playstatebutton", "embyProgressBarStyle", "emby-linkbutton"], function(itemContextMenu, dom, cardBuilder, itemShortcuts, itemHelper, mediaInfo, indicators, connectionManager, layoutManager, globalize, datetime, appHost, imageLoader, focusManager) { + "use strict"; + var supportsNativeLazyLoading = "loading" in HTMLImageElement.prototype; + + function getTextLinesHtml(textlines, isLargeStyle, allowTextWrap) { + var html = "", + isFirst = !0, + cssClass = "listItemBodyText"; + allowTextWrap || (cssClass += " listItemBodyText-nowrap"); + for (var i = 0, length = textlines.length; i < length; i++) { + var text = textlines[i]; + text && (html += isFirst ? isLargeStyle ? '

    ' : '
    ' : '
    ', html += text, html += isFirst && isLargeStyle ? "

    " : "
    ", isFirst = !1) + } + return html + } + + function getId(item) { + return item.Id + } + + function getListItemHtml(item, index, options) { + var enableOverview = options.enableOverview, + enableSideMediaInfo = options.enableSideMediaInfo, + clickEntireItem = options.clickEntireItem, + isLargeStyle = options.isLargeStyle, + enableContentWrapper = options.enableContentWrapper, + tagName = options.tagName, + action = options.action, + html = "", + downloadWidth = isLargeStyle ? 600 : 80; + enableContentWrapper && (html += '
    '); + var imgUrl, imageContainerClass, imageClass, playOnImageClick, imageAction, color, icon, iconCssClass, indicatorsHtml, progressHtml, serverId = item.ServerId, + apiClient = serverId ? connectionManager.getApiClient(serverId) : null, + itemType = item.Type; + !1 !== options.image && (imgUrl = cardBuilder.getImageUrl(item, apiClient, { + width: downloadWidth, + showChannelLogo: "channel" === options.imageSource + }).imgUrl, imageContainerClass = "listItemImageContainer", imageClass = "listItemImage", isLargeStyle && (imageContainerClass += " listItemImageContainer-large", layoutManager.tv && (imageContainerClass += " listItemImageContainer-large-tv")), options.roundImage && (imageClass += " listItemImage-round", imgUrl || (imageContainerClass += " listItemImageContainer-round")), playOnImageClick = options.imagePlayButton && !layoutManager.tv, clickEntireItem || (imageContainerClass += " itemAction"), options.playlistItemId && options.playlistItemId === item.PlaylistItemId && (imageContainerClass += " playlistIndexIndicatorImage"), imageAction = playOnImageClick ? "resume" : action, imgUrl || options.transparentIcon || (imageContainerClass += " defaultCardBackground defaultCardBackground0"), html += '
    ", imgUrl ? supportsNativeLazyLoading || 2 === options.lazy ? html += '' : html += '' : !options.enableDefaultIcon || (icon = cardBuilder.getDefaultIcon(item)) && (iconCssClass = "listItemIcon md-icon", options.transparentIcon && (iconCssClass += " listItemIcon-transparent"), html += '' + icon + ""), (indicatorsHtml = indicators.getPlayedIndicatorHtml(item, "listItem")) && (html += '
    ' + indicatorsHtml + "
    "), playOnImageClick && (html += ''), (progressHtml = indicators.getProgressBarHtml(item, { + containerClass: "listItemProgressBar" + })) && (html += progressHtml), html += "
    "), options.showIndexNumberLeft && (html += '
    ', null == item.IndexNumber ? html += " " : html += item.IndexNumber, html += "
    "); + var textlines = []; + options.showProgramDateTime && textlines.push(datetime.toLocaleString(datetime.parseISO8601Date(item.StartDate), { + weekday: "long", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit" + })), options.showAccessToken && textlines.push(item.AccessToken + " - " + item.AppName), options.showProgramTime && textlines.push(datetime.getDisplayTime(datetime.parseISO8601Date(item.StartDate))), options.showChannel && item.ChannelName && textlines.push(dom.htmlEncode(item.ChannelName)); + var parentTitle = null; + options.showParentTitle && ("Episode" === itemType ? parentTitle = item.SeriesName : (item.IsSeries || item.EpisodeTitle && item.Name) && (parentTitle = item.Name)); + var displayName = itemHelper.getDisplayName(item, { + includeParentInfo: options.includeParentInfoInTitle + }); + options.showIndexNumber && null != item.IndexNumber && (displayName = item.IndexNumber + ". " + displayName), options.showParentTitle && options.parentTitleWithTitle ? (displayName && (parentTitle && (parentTitle += " - "), parentTitle = (parentTitle || "") + displayName), textlines.push(dom.htmlEncode(parentTitle || ""))) : options.showParentTitle && textlines.push(dom.htmlEncode(parentTitle || "")), options.showLogLine && textlines.push(dom.htmlEncode(item)), displayName && !options.parentTitleWithTitle && textlines.push(dom.htmlEncode(displayName)); + var containerAlbumArtistIds, showArtist = !0 === options.artist, + artistItems = "MusicAlbum" === item.Type ? item.AlbumArtists : item.ArtistItems; + showArtist || !1 === options.artist || (containerAlbumArtistIds = options.containerAlbumArtistIds, artistItems && artistItems.length && !(1 < artistItems.length) && containerAlbumArtistIds && 1 === containerAlbumArtistIds.length && -1 !== containerAlbumArtistIds.indexOf(artistItems[0].Id) || (showArtist = !0)), showArtist && artistItems && textlines.push(artistItems.map(function(a) { + return a.Type = "MusicArtist", a.IsFolder = !0, + function(options, item, text, serverId, parentId, isSameItemAsCard) { + if (text = text || itemHelper.getDisplayName(item), layoutManager.tv) return dom.htmlEncode(text); + if (!1 === options.textLinks) return dom.htmlEncode(text); + var html = '" + }(options, a, null, item.ServerId) + }).join(", ") || ""), "Game" === itemType ? textlines.push(dom.htmlEncode(item.GameSystem)) : "TvChannel" === itemType && item.CurrentProgram && textlines.push(dom.htmlEncode(itemHelper.getDisplayName(item.CurrentProgram))), options.showDateCreated && textlines.push(datetime.toLocaleString(datetime.parseISO8601Date(item.DateCreated, !0))), options.showDateModified && textlines.push(datetime.toLocaleString(datetime.parseISO8601Date(item.DateModified, !0))), options.showDate && textlines.push(datetime.toLocaleString(datetime.parseISO8601Date(item.Date, !0))), options.showShortOverview && textlines.push(item.ShortOverview ? dom.htmlEncode(item.ShortOverview) : " "), options.showMediaStreamInfo && mediaInfo.pushMediaStreamLines(item, options, textlines, cardBuilder.getDefaultIcon(item)); + var cssClass = "listItemBody"; + clickEntireItem || (cssClass += " itemAction"), !1 === options.image && (cssClass += " listItemBody-noleftpadding"), !1 === options.verticalPadding && (cssClass += " listItemBody-noverticalpadding"), options.code && (cssClass += " listItemBody-code"), html += "<" + options.listItemBodyTagName + ' class="' + cssClass + '">'; + var sideMediaInfo, userData, likes, userDataButtonsHtml; + if (html += getTextLinesHtml(textlines, isLargeStyle, options.allowTextWrap), !1 !== options.mediaInfo && (enableSideMediaInfo || (html += '
    ' + mediaInfo.getPrimaryMediaInfoHtml(item, { + episodeTitle: !1, + originalAirDate: !1, + subtitles: !1, + endsAt: !1 + }) + "
    ")), enableOverview && item.Overview && (html += '
    ', html += dom.htmlEncode(item.Overview), html += "
    "), html += "", !1 !== options.mediaInfo && (!enableSideMediaInfo || (sideMediaInfo = mediaInfo.getPrimaryMediaInfoHtml(item, { + year: !1, + container: !1, + episodeTitle: !1, + criticRating: !1, + endsAt: !1 + })) && (html += '
    ' + sideMediaInfo + "
    ")), options.recordButton || "Timer" !== itemType && "Program" !== itemType || (html += indicators.getTimerIndicator(item).replace("indicatorIcon", "indicatorIcon listItemAside")), clickEntireItem || (options.addToListButton && (html += ''), options.openInNewWindowButton && appHost.supports("targetblank") && (html += 'open_in_new'), options.downloadButton && (html += ''), !1 !== options.moreButton && itemContextMenu.supportsContextMenu(item) && (html += ''), options.infoButton && (html += ''), options.deleteButton && (html += ''), options.overviewButton && item.Overview && (html += ''), options.rightButtons && (html += function(options) { + for (var html = "", i = 0, length = options.rightButtons.length; i < length; i++) { + var button = options.rightButtons[i]; + html += '" + } + return html + }(options)), !1 !== options.enableUserDataButtons && (likes = null == (userData = item.UserData || {}).Likes ? "" : userData.Likes, userDataButtonsHtml = "", itemHelper.canMarkPlayed(item) && (userDataButtonsHtml += ''), itemHelper.canRate(item) && (userDataButtonsHtml += ''), userDataButtonsHtml && (html += '' + userDataButtonsHtml + "")), options.dragHandle && (html += '')), enableContentWrapper && (html += "
    ", enableOverview && item.Overview && (html += '
    ', html += dom.htmlEncode(item.Overview), html += "
    ")), options.listItemParts) { + var attributes = itemShortcuts.getShortcutAttributes(item, options); + return action && attributes.push({ + name: "data-action", + value: action + }), attributes.push({ + name: "data-index", + value: index + }), options.addTabIndex && attributes.push({ + name: "tabindex", + value: "0" + }), options.draggable && attributes.push({ + name: "draggable", + value: "true" + }), { + attributes: attributes, + html: html + } + } + var dataAttributes = itemShortcuts.getShortcutAttributesHtml(item, options); + return action && (dataAttributes += ' data-action="' + action + '"'), dataAttributes += ' data-index="' + index + '"', options.addTabIndex && (dataAttributes += ' tabindex="0"'), options.draggable && (dataAttributes += ' draggable="true"'), "<" + tagName + ' class="' + options.className + '"' + dataAttributes + ">" + html + "" + } + + function setListOptions(items, options) { + options.enableContentWrapper = options.enableOverview && !layoutManager.tv, options.containerAlbumArtistIds = (options.containerAlbumArtists || []).map(getId), options.enableSideMediaInfo = null == options.enableSideMediaInfo || options.enableSideMediaInfo, options.clickEntireItem = !!layoutManager.tv || !(options.mediaInfo || options.moreButton || options.enableUserDataButtons || options.addToListButton || options.enableSideMediaInfo || options.enableOverview), options.isLargeStyle = "large" === options.imageSize, options.action = options.action || "link", options.tagName = options.clickEntireItem ? "button" : "div", options.listItemBodyTagName = "div"; + var cssClass = "listItem"; + (options.border || !1 !== options.highlight && !layoutManager.tv) && (cssClass += " listItem-border"), options.clickEntireItem && (cssClass += " itemAction"), "div" === options.tagName && (cssClass += " focusable", options.addTabIndex = !0), "none" === options.action && !options.clickEntireItem || (cssClass += " listItemCursor"), layoutManager.tv ? cssClass += " listItem-focusscale" : (cssClass += " listItem-touchzoom", cssClass += " listItem-touchzoom-transition"), layoutManager.tv || (options.draggable = !1 !== options.draggable, options.draggableSubItems = options.draggable && !1 !== options.draggableSubItems), options.isLargeStyle && (cssClass += " listItem-largeImage"), options.enableContentWrapper && (cssClass += " listItem-withContentWrapper"), !1 === options.verticalPadding && (cssClass += " listItem-noverticalpadding"), options.itemClass && (cssClass += " " + options.itemClass), options.dragHandle && options.draggable ? cssClass += " drop-target ordered-drop-target" : options.dropTarget && !layoutManager.tv && (cssClass += " drop-target full-drop-target"), options.className = cssClass; + var imageContainerClass, innerHTML = ""; + !1 !== options.image && (imageContainerClass = "listItemImageContainer", options.isLargeStyle && (imageContainerClass += " listItemImageContainer-large", layoutManager.tv && (imageContainerClass += " listItemImageContainer-large-tv")), innerHTML += '
    '), innerHTML += "<" + options.listItemBodyTagName + ' class="listItemBody">'; + var textlines = []; + options.showDateModified && textlines.push(" "), options.showDateCreated && textlines.push(" "), options.showDate && textlines.push(" "), options.showShortOverview && textlines.push(" "), options.showProgramDateTime && textlines.push(" "), options.showProgramTime && textlines.push(" "), options.showChannel && textlines.push(" "), options.showLogLine && textlines.push(" "), options.showAccessToken && textlines.push(" "), (options.showParentTitle && options.parentTitleWithTitle || options.showParentTitle) && textlines.push(" "), options.parentTitleWithTitle || textlines.push(" "), textlines.length < 1 && textlines.push(" "), textlines.length < 2 && textlines.push(" "), innerHTML += getTextLinesHtml(textlines, options.isLargeStyle, options.allowTextWrap), options.enableOverview && (innerHTML += '
    ', !1 !== options.mediaInfo && (options.enableSideMediaInfo || (innerHTML += '
    ')), innerHTML += "
    "), innerHTML += "", options.dragHandle && (innerHTML += ''), options.templateInnerHTML = innerHTML + } + + function getListViewHtml(items, options) { + setListOptions(0, options); + for (var groupTitle = "", html = "", i = 0, length = items.length; i < length; i++) { + var itemGroupTitle, item = items[i]; + !options.showIndex || (itemGroupTitle = function(item, options) { + if ("disc" !== options.index) return ""; + var parentIndexNumber = item.ParentIndexNumber; + return 1 === parentIndexNumber || null == parentIndexNumber ? "" : globalize.translate("ValueDiscNumber", parentIndexNumber) + }(item, options)) !== groupTitle && (html += 0 === i ? '

    ' : '

    ', html += itemGroupTitle, html += "

    ", groupTitle = itemGroupTitle), html += getListItemHtml(item, i, options) + } + return html + } + return supportsNativeLazyLoading = !1, { + getListViewHtml: getListViewHtml, + getItemsHtml: getListViewHtml, + getListItemHtml: getListItemHtml, + setListOptions: setListOptions, + getItemParts: function(item, index, options) { + return options.listItemParts = !0, getListItemHtml(item, index, options) + }, + buildItems: function(items, options) { + var itemsContainer = options.itemsContainer; + if (document.body.contains(itemsContainer)) { + var parentContainer = options.parentContainer; + if (parentContainer) { + if (!items.length) return void parentContainer.classList.add("hide"); + parentContainer.classList.remove("hide") + } + var html = getListViewHtml(items, options); + itemsContainer.innerHTML = html, itemsContainer.items = items, html && imageLoader.lazyChildren(itemsContainer), options.autoFocus && focusManager.autoFocus(itemsContainer) + } + } + } +}); \ No newline at end of file diff --git a/lib/web/htdocs/modules/listview/menu.js b/lib/web/htdocs/modules/listview/menu.js new file mode 100644 index 0000000000000000000000000000000000000000..4c50c32c1a9529d81b470989889a97c29436b950 --- /dev/null +++ b/lib/web/htdocs/modules/listview/menu.js @@ -0,0 +1,83 @@ +$(document).ready(function(){ +}); + +function show_menu(_button, _menuid) { + switch_display(_button, _menuid); + reset_menu_posn(_button, _menuid); + return false; +} + +function reset_menu_posn(_el_button, _menuid) { + menuid = '#' + _menuid; + + var _el_menu_rect = $(menuid)[0].getBoundingClientRect(); + var _el_button_rect = _el_button.getBoundingClientRect(); + var _el_menu_height = _el_menu_rect.height; + var _el_button_height = _el_button_rect.height; + var _el_button_left = _el_button.scrollLeft; + + if ( _el_button_rect.y + _el_button_height < _el_button_height ) { + _top = _el_button.offsetTop - _el_button_rect.y; + } else { + _top = _el_button.offsetTop - _el_menu_height + _el_button_height; + } + var _el_button_width = _el_button_rect.width; + var _el_menu_width = _el_menu_rect.width; + + var panel_width = _el_button.parentElement.getBoundingClientRect().width; + if ( panel_width - _el_button_width - 5 < _el_menu_width ) { + _left = _el_button_left + _el_button_width/2; + } else { + _left = _el_button_left + _el_button_width; + } + $(menuid).css({"top":_top+'px', "left":_left+'px'}); + + var _el_menu_rect = $(menuid)[0].getBoundingClientRect(); + var _el_menu_height = _el_menu_rect.height; + if ( _el_button_rect.y + _el_button_height < _el_menu_height ) { + _top = _el_button.offsetTop - _el_button_rect.y; + } else { + _top = _el_button.offsetTop - _el_menu_height + _el_button_height; + } + $(menuid).css({"top":_top+'px', "left":_left+'px'}); +} + +function switch_display(_button, _menuid) { + $('div#'+_menuid).each(function() { + if ( $(this).hasClass("listItemHide") ) { + $(this).removeClass("listItemHide"); + $(this).addClass("listItemShow"); + $(this).focus().select(); + $(document).on('click', function(e) { + var _menu = $('#'+_menuid); + var _button2 = $('.menuSection'); + if ( !$(e.target).closest(_menu).length ) { + if ( !$(e.target).closest(_button2).length ) { + switch_display(_button, _menuid); + } + } + }); + $('#menuForm').submit(function(e) { // catch the form's submit + // ajax does not submit name/value of button, so use hidden input + $('input:hidden[name=action]').val(e.originalEvent.submitter.value); + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), // GET or POST + url: $(this).attr('action'), + success: function(response) { // on success + $('#menuActionStatus').html(response); + } + }); + return false; // cancel original submit event + }); + + } else { + $(this).removeClass("listItemShow"); + $(this).addClass("listItemHide"); + $(document).prop('onclick', null).off("click"); + $('#menuForm').prop("onsubmit", null).off("submit"); + } + }); + +} + diff --git a/lib/web/htdocs/modules/navdrawer/__init__.py b/lib/web/htdocs/modules/navdrawer/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/navdrawer/navdrawer.css b/lib/web/htdocs/modules/navdrawer/navdrawer.css new file mode 100644 index 0000000000000000000000000000000000000000..7cf897462902a1892a9ad7a224bb659aa1ec0c55 --- /dev/null +++ b/lib/web/htdocs/modules/navdrawer/navdrawer.css @@ -0,0 +1,373 @@ +.mainDrawer { + padding: 1em 1.1em 10vh; + padding-top: -webkit-calc(1em + var(--window-inset-top)); + padding-top: calc(1em + var(--window-inset-top)); + display: -webkit-box; + display: -webkit-flex; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + flex-direction: column; + overscroll-behavior-y: contain; + position: fixed; + top: 0; + left: 0; + bottom: 0; + bottom: var(--window-inset-bottom); + contain: strict; + z-index: auto; + width: 40%; + -webkit-transform: translateX(-100%); + transform: translateX(-100%); + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +.drawer-docked { + width: 13em; +} +.mainDrawer:not(.drawer-docked) { + -webkit-transition: -webkit-transform 0.33s cubic-bezier(0, 0, 0.3, 1); + -o-transition: transform 0.33s cubic-bezier(0, 0, 0.3, 1); + transition: transform 0.33s cubic-bezier(0, 0, 0.3, 1); +} +@media all and (min-width: 40em) { + .mainDrawer { + width: 30%; + } +} +@media all and (min-width: 55em) { + .mainDrawer { + width: 18.6em; + } +} +.drawer-open { + -webkit-box-shadow: 0.14em 0 0.84em rgba(0, 0, 0, 0.4); + box-shadow: 0.14em 0 0.84em rgba(0, 0, 0, 0.4); + -webkit-transform: none; + transform: none; +} +.drawer-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + bottom: var(--window-inset-bottom); + background-color: #000; + opacity: 0; + z-index: 1098; + will-change: opacity; + contain: strict; + -webkit-animation: drawer-backdrop-fadein 0.38s ease-in-out normal both; + animation: drawer-backdrop-fadein 0.38s ease-in-out normal both; + overflow: hidden; + overscroll-behavior: contain; +} +.drawer-backdrop-fadeout { + -webkit-animation: drawer-backdrop-fadeout 0.38s ease-in-out normal both; + animation: drawer-backdrop-fadeout 0.38s ease-in-out normal both; +} +@-webkit-keyframes drawer-backdrop-fadein { + from { + opacity: 0; + } + to { + opacity: 0.5; + } +} +@keyframes drawer-backdrop-fadein { + from { + opacity: 0; + } + to { + opacity: 0.5; + } +} +@-webkit-keyframes drawer-backdrop-fadeout { + from { + opacity: 0.5; + } + to { + opacity: 0; + } +} +@keyframes drawer-backdrop-fadeout { + from { + opacity: 0.5; + } + to { + opacity: 0; + } +} +.navDrawerCollapseSection { + margin: 0.9em 0 0.3em !important; +} +.navDrawerCollapseButton { + margin: 0 !important; + border: 0 !important; + padding: 0.25em 0 !important; +} +.navDrawerCollapseContent { + border: 0 !important; + padding: 0 !important; +} +.navMenuOption { + display: -webkit-box !important; + display: -webkit-flex !important; + display: flex !important; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + text-decoration: none; + color: inherit; + vertical-align: middle; + -webkit-flex-shrink: 0; + flex-shrink: 0; + font-weight: 400 !important; + margin: 0.1em 0 !important; + padding: 0.6em 0 0.6em 0.7em !important; + -webkit-border-radius: 0.42em !important; + border-radius: 0.42em !important; +} +.navMenuOption.emby-button-focusscale:focus { + -webkit-transform: scale(1.025); + transform: scale(1.025); +} +.navMenuOption-selected { + font-weight: 700 !important; + background-color: var(--theme-icon-focus-background) + color: var(--theme-accent-text-color) +} +.navDrawerLogo { + width: 100%; + padding: 0.9em 0; + background-position: 0.4em center; + background-repeat: no-repeat; + -webkit-background-size: contain; + background-size: contain; + margin: 0 !important; + -webkit-box-flex: 0; + -webkit-flex-grow: 0; + flex-grow: 0; +} +.navDrawerHeader { + margin: 1.25em 0 1.5em; + position: relative; +} +.btnPinNavDrawer { + position: absolute !important; + right: -0.5em; + font-size: 84% !important; + margin: 0 !important; +} +.btnPinNavDrawer-iconpin { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); +} +.navMenuOptionIcon { + margin-right: 0.7em; + -webkit-flex-shrink: 0; + flex-shrink: 0; + font-size: 1.7em; +} +.navMenuOptionText { + overflow: hidden; + -o-text-overflow: ellipsis; + text-overflow: ellipsis; + white-space: nowrap; +} +.navMenuHeader { + padding-left: 0.4em !important; + margin: 0.75em 0 0.25em; + display: -webkit-box; + display: -webkit-flex; + display: flex; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + -webkit-flex-shrink: 0; + flex-shrink: 0; + -webkit-flex-wrap: wrap; + flex-wrap: wrap; +} +.navDrawerCollapseIcon { + right: 0 !important; +} +@media all and (pointer: fine) and (min-width: 60em) { + .navDrawerCollapseIcon { + right: initial !important; + left: 8.7em; + } +} +.mainDrawer-open-partial .navMenuOptionIcon { + margin-right: 0; + font-size: 2em; +} +.mainDrawer-open-partial .navMenuHeader { + padding: 0 0.5em; + -webkit-box-pack: center; + -webkit-justify-content: center; + justify-content: center; + margin: 0.75em 0; + font-size: inherit; + text-align: center; +} +.mainDrawer-open-partial .navMenuOption { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + flex-direction: column; + -webkit-box-pack: center; + -webkit-justify-content: center; + justify-content: center; + padding: 1.1em 0.5em !important; + font-size: 78%; +} +.mainDrawer-open-partial .navMenuOptionText { + white-space: initial; + display: none; +} +.navDrawerListItem .listItemImageContainer { + width: 1.9em !important; + height: 1.9em !important; +} +.navDrawerListItem .listItemBody { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + + + +.toggle { + display:none; +} +.wrap-collabsible { + margin: 1.2rem 0; +} +.label-toggle { + display: block; + font-weight: bold; + font-family: monospace; + font-size: 1.2rem; + text-align: center; + padding: 1rem; + color: #ddd; + background: #0069ff; + cursor: pointer; + border-radius: 7px; + transition: all 0.25s ease-out; +} +.label-toggle:hover { + color: #fff; +} +.label-toggle::before { + content: " "; + display: inline-block; + border-bottom: 3px solid currentColor; + border-left: 3px solid currentColor; + vertical-align: middle; + margin-left: 3px; + margin-top: -3px; + width: 8px; + height: 8px; + transform: rotate(230deg); + transition: transform 0.6s ease-in-out; + position: absolute; + right: 0.5em; +} +.toggle:checked + .label-toggle::before { + transform-origin: 50% 50%; + transition: transform 600ms ease-in-out; + position: absolute; + right: 0.5em; + transform: rotate(-45deg); +} +.collapsible-content { + max-height: 0px; + overflow: hidden; + transition: max-height 0.60s ease-in-out; +} +.toggle:checked + .label-toggle + .collapsible-content { + max-height: 1200px; +} +.toggle:checked + .lbl-toggle { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.collapsible-content .content-inner { + border-bottom-left-radius: 7px; + border-bottom-right-radius: 7px; + padding: 0.5rem 1rem; +} +.collapsible-content p { + margin-bottom: 0; +} + + + +.navCollapsibleButton { + margin: 0; + display: -webkit-box; + display: -webkit-flex; + display: flex; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + text-transform: none; + width: 100%; + text-align: left; + text-transform: none; + border-width: 0; + padding-left: 0.1em; + background: 0 0; + -webkit-box-shadow: none; + box-shadow: none; + -webkit-border-radius: 0 !important; + border-radius: 0 !important; +} + +.navButton { + position: relative; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: inline-flex; + -webkit-box-align: center; + -webkit-align-items: center; + align-items: center; + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin: 0 0.29em; + text-align: center; + font-size: inherit; + font-family: inherit; + color: inherit; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; + z-index: 0; + padding: 0.66em 1em; + vertical-align: middle; + border: 0; + vertical-align: middle; + -webkit-border-radius: 0.42em; + border-radius: 0.42em; + position: relative; + font-weight: 500; + -webkit-tap-highlight-color: transparent; + text-decoration: none; + line-height: inherit; +} + +.status-note { + bottom: 8px; + background: yellow; + color: blue; + border-radius: 8px; + padding-left: 5px; + padding-right: 5px; + font-size: 70%; + margin-left: 5px; +} diff --git a/lib/web/htdocs/modules/navdrawer/navdrawer.js b/lib/web/htdocs/modules/navdrawer/navdrawer.js new file mode 100644 index 0000000000000000000000000000000000000000..1b8c4cdf05f6912358e0581b47993d30d28e58f1 --- /dev/null +++ b/lib/web/htdocs/modules/navdrawer/navdrawer.js @@ -0,0 +1,23 @@ +$(document).ready(function() { + $('.navOpenPanel').css("z-index","1"); + $('.navOpenPanel').on("click", function() { + $('div.mainDrawer').show(500); + $('div.skinBody-withFullDrawer').css("left", ""); + $('button.navOpenPanel').hide(); + }); + $('button.navClosePanel').each(function() { + $(this).click(function() { + $('div.mainDrawer').hide(500); + $('div.skinBody-withFullDrawer').css({"left": "0em", "transition": "left .5s"}); + $('button.navOpenPanel').show(); + }); + }); + $('button.navOpenPanel').hide(); + $('a.navMenuOption').on("click", function() { + $('a.navMenuOption').removeClass('navMenuOption-selected'); + $(this).addClass('navMenuOption-selected'); + }); + + + +}); \ No newline at end of file diff --git a/lib/web/htdocs/modules/pages/__init__.py b/lib/web/htdocs/modules/pages/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/pages/configform.js b/lib/web/htdocs/modules/pages/configform.js new file mode 100644 index 0000000000000000000000000000000000000000..453549fead387f60924528740533bc6e82f861b0 --- /dev/null +++ b/lib/web/htdocs/modules/pages/configform.js @@ -0,0 +1,143 @@ +$(document).ready(function(){ + $('form select[class=dlevelsetting]').change(function() { + setDisplayLevel(); + }); + + var getConfigData = function(){ + $.getJSON("/config.json", function(json) { + if (json != "Nothing found."){ + var currentDisplayLevel = $('form select[class=dlevelsetting]').val(); + if (typeof currentLevelValue === 'undefined') { + currentDisplayLevel = json.display.display_level; + $("form select[class=dlevelsetting]").val(currentDisplayLevel); + } + $('.sectionForm').each(function(){ + populateForm("#"+$(this).attr("id"), json, null) + setDisplayLevel("#"+$(this).attr("id")) + }) + } else { + $('#status').html('

    Were afraid nothing was returned. is the web interface disabled?

    '); + } + return true; + }) + .fail(function() { + $('#status').html('

    Unable to obtain config data. Is the config web interface disabled?

    '); + $('button#submit').prop("disabled",true); + return false; + }) + return false; + } + + function populateForm(form,data,parent) { + $.each(data, function(key, value) { + if(value !== null) { + if (typeof value === 'object' ) { + populateForm(form,value,key+'-') + } else { + if (parent === null) { + var ctrl = $('[name='+key+']', form); + } else { + var ctrl = $('[name='+parent+key+']', form); + } + switch(ctrl.prop("type")) { + case "radio": case "checkbox": + ctrl.each(function() { + if ($(this).attr('value') == value) $(this).attr("checked",value); + $(this).prop("checked",value); + }); + break; + case undefined: + break; + case "text": case "hidden": case "password": + ctrl.val(value); + vallength = value.length+5 + if (vallength < 15) { + vallength = 15 + } + ctrl.attr('size', vallength) + break; + default: + ctrl.val(value); + } + } + } + }); + } + + function getDisplayLevel() { + var currentDisplayLevel = $('form select[class=dlevelsetting]').val(); + if (typeof currentLevelValue === 'undefined') { + currentDisplayLevel = $('select[class=dlevelsetting]').val(); + } + if (currentDisplayLevel == '') { + currentDisplayLevel = '1-Standard' + } + return currentDisplayLevel; + } + x=1 + function setDisplayLevel() { + x+=1 + var currentDisplayLevel = getDisplayLevel(); + var currentLevel = currentDisplayLevel.match(/^\d+/)[0]; + $('form tr[class^="dlevel"]').each( + function(index) { + var input = $(this) + var itemLevel = input.attr('class').match(/\d+$/)[0]; + if (itemLevel > currentLevel) { + $(this).hide() + } else { + $(this).show() + } + }); + $('form tr[class="hlevel"]').each( + function() { + allHidden = true; + $(this).nextUntil('tr[class="hlevel"]').each( + function() { + if ( $(this).css("display") != "none" ) { + allHidden = false; + } + } + ) + if (allHidden) { + $(this).hide() + } else { + $(this).show() + } + }); + $('a.configTab').hide(); + $('form[class="sectionForm"]').each( + function() { + formname = $(this).attr("id"); + $(this).find('tr[class^="dlevel"]').each( + function(i) { + if ( this.style.display !== "none" ) { + // at least one is active. turn on tab + $('a.'+formname).show(); + } + } + ) + }); + } + getConfigData(); + $('form').submit(function() { // catch the form's submit + $(this).find('input[type="checkbox"]').each(function() { + if ($(this).is(":checked") == true) { + var n = '#'+$(this).attr("id")+'hidden'; + $(n).prop("disabled", true); + } else { + var n = '#'+$(this).attr("id")+'hidden'; + $(n).prop("disabled", false); + } + }); + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), // GET or POST + url: $(this).attr('action'), + success: function(response) { // on success + $('#status').html(response); + } + }); + return false; // cancel original submit event + }); +}); diff --git a/lib/web/htdocs/modules/pluginmgr/__init__.py b/lib/web/htdocs/modules/pluginmgr/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/pluginmgr/pluginmgr.js b/lib/web/htdocs/modules/pluginmgr/pluginmgr.js new file mode 100644 index 0000000000000000000000000000000000000000..681edf2d61cf3b75585105453b1b2741d3743883 --- /dev/null +++ b/lib/web/htdocs/modules/pluginmgr/pluginmgr.js @@ -0,0 +1,58 @@ +function load_form_url(url) { + $("#pluginscontent").load(url); + $('div#pluginscontent').each(function() { + $(this).removeClass("pluginHide"); + $(this).addClass("pluginShow"); + }); + $('div#plugincontent').each(function() { + $(this).removeClass("pluginShow"); + $(this).addClass("pluginHide"); + }); + return false; +} + +function load_plugin_url(url) { + $("#plugincontent").load(url); + $('div#pluginscontent').each(function() { + $(this).removeClass("pluginShow"); + $(this).addClass("pluginHide"); + }); + $('div#plugincontent').each(function() { + $(this).removeClass("pluginHide"); + $(this).addClass("pluginShow"); + }); + + $(document).ready(setTimeout(function(){ + $('form').submit(function(e) { // catch the form's submit + // ajax does not submit name/value of button, so use hidden input + $('input:hidden[name=action]').val(e.originalEvent.submitter.value); + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), // GET or POST + url: $(this).attr('action'), + success: function(response) { // on success + $('#menuActionStatus').html(response); + } + }); + return false; // cancel original submit event + }); + }, 500)); + + + + + + return false; +} + +function display_plugins() { + $('div#pluginscontent').each(function() { + $(this).removeClass("pluginHide"); + $(this).addClass("pluginShow"); + }); + $('div#plugincontent').each(function() { + $(this).removeClass("pluginShow"); + $(this).addClass("pluginHide"); + }); + return false; +} diff --git a/lib/web/htdocs/modules/pluginmgr/pluginmgrform.css b/lib/web/htdocs/modules/pluginmgr/pluginmgrform.css new file mode 100644 index 0000000000000000000000000000000000000000..241d34dea2bfbcce53557b1b27d485214ddd7e9e --- /dev/null +++ b/lib/web/htdocs/modules/pluginmgr/pluginmgrform.css @@ -0,0 +1,114 @@ +textarea, input, select { + font-size: 90%; + font-weight: inherit; + text-decoration: none; + font-family: inherit; +} + +.plugin_list { + display: flex; + flex-wrap: wrap; +} + +.plugin_item { + text-align: center; + display: flex; + justify-content: end; + flex-direction: column; + color: var(--theme-text-color); + background-color: transparent; + border: none; + line-height: 1; + margin: 5px; + margin-bottom: 20px; +} + +.plugin_item:hover { + opacity: 0.6; + cursor: pointer; +} + +.pluginText-secondary { + color: var(--theme-body-secondary-text-color); + font-size: 90%; +} + +.pluginHide{ + display: none; +} +.pluginShow{ + display: block; +} + +.pluginIcon{ + min-width: 2em; + font-size: 150%; + text-align: center; + display: table-cell; +} +.pluginSection{ + display: inline-table; + border: 3px solid var(--card-background); + padding-right: 10px; + padding-left: 10px; + min-width: 25em; + background: var(--card-background); +} +.pluginSectionName{ + font-size: 130%; + font-weight: bold; + display: flex; + flex-direction: column; + justify-content: end; +} +.pluginValue{ + display: flex; + justify-content: center; + flex-direction: column; + margin-left: 10px; + margin-bottom: 2px; +} +div.pluginIcon a { + color: inherit; +} + +.pluginIconButton{ + background: #ffffff61; + width: 2em; + height: 2em; + border-radius: 50%; + padding: 0px; + border: 0px; + margin-left: 1em; + cursor: pointer; +} + +.image-size{ + width:20vw; +} +.icon-size{ + width:5vw; +} + +.bottom-left { + position: absolute; + bottom: 8px; + left: 8px; + background: yellow; + color: blue; + border-radius: 8px; + padding-left: 5px; + padding-right: 5px; + padding-top: 3px; + padding-bottom: 3px; +} + +@media only screen and (max-width:60em){ + .image-size{ + width:40vw; + } + .icon-size{ + width:12vw; + } + +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/pluginmgr/pluginmgrform.js b/lib/web/htdocs/modules/pluginmgr/pluginmgrform.js new file mode 100644 index 0000000000000000000000000000000000000000..ff463da0f1f301ecd34e2ec7bade63eed7aa42a6 --- /dev/null +++ b/lib/web/htdocs/modules/pluginmgr/pluginmgrform.js @@ -0,0 +1,16 @@ +$(document).ready(setTimeout(function(){ + $('form').submit(function(e) { // catch the form's submit + // ajax does not submit name/value of button, so use hidden input + $('input:hidden[name=action]').val(e.originalEvent.submitter.value); + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), // GET or POST + url: $(this).attr('action'), + success: function(response) { // on success + $('#menuActionStatus').html(response); + } + }); + return false; // cancel original submit event + }); + +}, 500)); diff --git a/lib/web/htdocs/modules/scheduler/__init__.py b/lib/web/htdocs/modules/scheduler/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/scheduler/scheduler.css b/lib/web/htdocs/modules/scheduler/scheduler.css new file mode 100644 index 0000000000000000000000000000000000000000..47f96e02e8884f67f130542800152380d73fbb1d --- /dev/null +++ b/lib/web/htdocs/modules/scheduler/scheduler.css @@ -0,0 +1,87 @@ +.schedTable{ + background: var(--docked-drawer-background); + width: 95%; +} +.schedIcon{ + min-width: 2em; + font-size: 150%; + text-align: center; + display: table-cell; +} + +@media not all and (min-width:50em){ + .schedIcon{ + min-width:1.3em; + } + .schedTable{ + width: 99%; + } +} +@media not all and (max-width:60em){ + .schedIcon{ + min-width:3em; + } +} +.schedTask{ + width:90%; +} +.schedTitle{ + font-size: 110%; + font-weight: bold; +} +.schedSection{ + font-size: 130%; + font-weight: bold; +} +.schedHide{ + display: none; +} +.schedShow{ + display: block; +} +.schedIconButton{ + background: #ffffff61; + width: 2em; + height: 2em; + border-radius: 50%; + padding: 0px; + border: 0px; + margin-left: 1em; + cursor: pointer; +} +td a { + color: inherit; + text-decoration: none; +} + +.progress-line, .progress-line:before { + height: 3px; + width: 100%; + margin: 0; +} +.progress-line { + background-color: #878bc7; + display: -webkit-flex; + display: flex; +} +.progress-line:before { + background-color: #ed344a; + content: ''; + -webkit-animation: running-progress 2s cubic-bezier(0.4, 0, 0.2, 1) infinite; + animation: running-progress 3s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} +@-webkit-keyframes running-progress { + 0% { margin-left: 0px; margin-right: 100%; } + 50% { margin-left: 25%; margin-right: 0%; } + 100% { margin-left: 100%; margin-right: 0; } +} +@keyframes running-progress { + 0% { margin-left: 0px; margin-right: 100%; } + 50% { margin-left: 25%; margin-right: 0%; } + 100% { margin-left: 100%; margin-right: 0; } +} + + + +#background-image: linear-gradient(45deg, #ed344a 25%, #857830 25%, #857830 50%, #ed344a 50%, #ed344a 75%, #857830 75%, #857830 100%); +#background-size: 56.57px 56.57px; diff --git a/lib/web/htdocs/modules/scheduler/scheduler.js b/lib/web/htdocs/modules/scheduler/scheduler.js new file mode 100644 index 0000000000000000000000000000000000000000..2a885733f7a876135fb65ffccad78c4ddd815ba3 --- /dev/null +++ b/lib/web/htdocs/modules/scheduler/scheduler.js @@ -0,0 +1,140 @@ +$(document).ready(function() { + if (timeout != undefined) { + clearTimeout(timeout); + timeout = null; + } + $('div.progress-line:first').each(function() { + reload_div(); + $(this).closest("td").find("span").each(function() { + $(this).addClass("progress-line") + }); + }); + $('td div label').each(function() { + if ( schedState[$(this).text()] ) { + $(this).siblings('input').prop('checked', true); + } + }); + $(":checkbox").change(function() { + schedState[$(this).siblings('label').text()] = $(this).prop('checked'); + }); +}); +var timeout; +function reload_div(){ + timeout = setTimeout(function(){ + display_status = $("#schedtasks").css('display'); + if (display_status == 'none') { + reload_div(); + } + else if (display_status == 'block') { + qs = ""; + for (let k in schedState) { + if (schedState[k]) { + qs = qs + k + "=1&" + } + } + load_sched_url('/api/schedulehtml?' + qs); + } + }, 5000); +} +function load_task_url(url) { + $("#schedtask").load(url); + $('div#schedtasks').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('div#schedtask').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + return false; +} + +function load_sched_url(url) { + $("#content").load(url); + return false; +} + +function display_tasks() { + $('div#schedtasks').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('div#schedtask').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); +} + +function onChangeTimeType(timetype) { + if (timetype.value == 'weekly') { + $('#divDOW').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divTOD').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divINTL').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divRND').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + } else if (timetype.value == 'daily') { + $('#divDOW').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divTOD').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divINTL').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divRND').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + } else if (timetype.value == 'interval') { + $('#divINTL').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divRND').each(function() { + $(this).removeClass("schedHide"); + $(this).addClass("schedShow"); + }); + $('#divDOW').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divTOD').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + } else if (timetype.value == 'startup') { + $('#divINTL').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divRND').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divDOW').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + $('#divTOD').each(function() { + $(this).removeClass("schedShow"); + $(this).addClass("schedHide"); + }); + } + +} + diff --git a/lib/web/htdocs/modules/scheduler/trigger.js b/lib/web/htdocs/modules/scheduler/trigger.js new file mode 100644 index 0000000000000000000000000000000000000000..559af5be163432e3041b0cceeb08c6f4de43e1b5 --- /dev/null +++ b/lib/web/htdocs/modules/scheduler/trigger.js @@ -0,0 +1,13 @@ +$(document).ready(setTimeout(function(){ + $('form').submit(function() { + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), // GET or POST + url: $(this).attr('action'), + success: function(response) { // on success + $('#status').html(response); + } + }); + return false; + }); +}, 100)); \ No newline at end of file diff --git a/lib/web/htdocs/modules/scrollstyles.css b/lib/web/htdocs/modules/scrollstyles.css new file mode 100644 index 0000000000000000000000000000000000000000..857ff779bbbf437ec3275d2184b60111b9c0d51a --- /dev/null +++ b/lib/web/htdocs/modules/scrollstyles.css @@ -0,0 +1,77 @@ +@media (hover: hover) and (pointer: fine) { + ::-webkit-scrollbar { + width: 1em; + height: 1em; + } + .scrollX-mini::-webkit-scrollbar { + height: 0.6em; + } + .scrollY-mini::-webkit-scrollbar { + width: 0.6em; + } + .scrollX-mini { + scrollbar-width: thin; + } + .scrollY-mini { + scrollbar-width: thin; + } +} +::-webkit-scrollbar-thumb { + -webkit-border-radius: 0.42em; + border-radius: 0.42em; +} +.scrollX { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + overflow-y: hidden; + white-space: nowrap; +} +.smoothScrollX { + scroll-behavior: smooth; +} +.hiddenScrollX, +.layout-tv .scrollX { + -ms-overflow-style: none; + scrollbar-width: none; +} +.hiddenScrollX::-webkit-scrollbar, +.layout-tv .scrollX::-webkit-scrollbar { + height: 0 !important; + display: none; +} +.scrollY { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overflow-x: hidden; +} +.smoothScrollY { + scroll-behavior: smooth; +} +.overflowYScroll { + overflow-y: scroll; +} +.hiddenScrollY, +.layout-tv .scrollY { + -ms-overflow-style: none; + scrollbar-width: none; +} +.hiddenScrollY::-webkit-scrollbar, +.layout-tv .scrollY::-webkit-scrollbar { + width: 0 !important; + display: none; +} +@media (hover: hover) { + .hiddenScrollY-hover:not(:hover) { + -ms-overflow-style: none; + scrollbar-width: none; + } + .hiddenScrollY-hover:not(:hover)::-webkit-scrollbar { + width: 0 !important; + display: none; + } +} +.scrollSliderY { + width: 100%; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} diff --git a/lib/web/htdocs/modules/table/__init__.py b/lib/web/htdocs/modules/table/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/table/asc_b.gif b/lib/web/htdocs/modules/table/asc_b.gif new file mode 100644 index 0000000000000000000000000000000000000000..cb8887ea23d4944a676f84b8f65c7dcdbc7c64a6 Binary files /dev/null and b/lib/web/htdocs/modules/table/asc_b.gif differ diff --git a/lib/web/htdocs/modules/table/asc_w.gif b/lib/web/htdocs/modules/table/asc_w.gif new file mode 100644 index 0000000000000000000000000000000000000000..1f18372950ca883b8ca696bf7123e5b22725af23 Binary files /dev/null and b/lib/web/htdocs/modules/table/asc_w.gif differ diff --git a/lib/web/htdocs/modules/table/both_b.gif b/lib/web/htdocs/modules/table/both_b.gif new file mode 100644 index 0000000000000000000000000000000000000000..7bf1e4a27952eacfd925f7580216fba320595efd Binary files /dev/null and b/lib/web/htdocs/modules/table/both_b.gif differ diff --git a/lib/web/htdocs/modules/table/both_w.gif b/lib/web/htdocs/modules/table/both_w.gif new file mode 100644 index 0000000000000000000000000000000000000000..9b0ee7b3b496135b838a7d1e29c3fe4c2cd3683d Binary files /dev/null and b/lib/web/htdocs/modules/table/both_w.gif differ diff --git a/lib/web/htdocs/modules/table/desc_b.gif b/lib/web/htdocs/modules/table/desc_b.gif new file mode 100644 index 0000000000000000000000000000000000000000..59249059573c663c390e800ff57399de70f69958 Binary files /dev/null and b/lib/web/htdocs/modules/table/desc_b.gif differ diff --git a/lib/web/htdocs/modules/table/desc_w.gif b/lib/web/htdocs/modules/table/desc_w.gif new file mode 100644 index 0000000000000000000000000000000000000000..c8e0a479da24e77de5d4493c2cca0e2107fe1fef Binary files /dev/null and b/lib/web/htdocs/modules/table/desc_w.gif differ diff --git a/lib/web/htdocs/modules/table/table.css b/lib/web/htdocs/modules/table/table.css new file mode 100644 index 0000000000000000000000000000000000000000..e01c92231365748f2610a509759af95fd6645c6d --- /dev/null +++ b/lib/web/htdocs/modules/table/table.css @@ -0,0 +1,120 @@ +div#tablecontent { + overflow-x: auto; +} +table thead { + font-size: 0.9em; + line-height: 1.5; + /* https://www.colorzilla.com/gradient-editor/ */ + background: linear-gradient(to bottom, rgb(50, 50, 80) 0%,rgb(88, 88, 100) 36%,rgb(121, 121, 170) 87%,rgb(140, 140, 181) 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */ +} +td.enabled { + background: rgba(0,255,133,0.6); +} +td.disabled { + background: rgba(255,0,0,0.6); +} +td.duplicate { + background: rgba(255,255,0,0.7); +} +td.duplicate_disabled { + background: rgba(255,0,108,0.7); +} +img.filterit { + padding: 8px 8px 13px 5px; + background-image: url("asc_w.gif"); + background-repeat: no-repeat; + background-position: right center; + float: right; + cursor: pointer; +} +img.filterit:hover { + padding: 8px 8px 13px 5px; + background-image: linear-gradient(to right, rgba(200, 200, 255, 0), rgba(255, 255, 255, 0.5)), url("asc_w.gif"); + background-repeat: no-repeat; + background-position: right center; + float: right; + cursor: pointer; +} +img.sortit { + margin-bottom: -6px; + padding: 14px 8px 8px 5px; + cursor: pointer; +} +img.sortnone { + background: url("both_w.gif"); + background-repeat: no-repeat; + background-position: left center; +} +img.sortnone:hover { + background: linear-gradient(to right, rgba(200, 200, 255, 0), rgba(255, 255, 255, 0.5)), url("both_w.gif"); + background-repeat: no-repeat; + background-position: left center; +} +img.sortasc { + background: url("asc_w.gif"); + background-repeat: no-repeat; + background-position: left center; +} +img.sortasc:hover { + background: linear-gradient(to right, rgba(200, 200, 255, 0), rgba(255, 255, 255, 0.5)), url("asc_w.gif"); + background-repeat: no-repeat; + background-position: left center; +} +img.sortdesc { + background: url("desc_w.gif"); + background-repeat: no-repeat; + background-position: left center; +} +img.sortdesc:hover { + background: linear-gradient(to right, rgba(200, 200, 255, 0), rgba(255, 255, 255, 0.5)), url("desc_w.gif"); + background-repeat: no-repeat; + background-position: left center; +} +.vertline{ + height: 100%; + float: right; + border-right: 1px ridge rgb(230,230,230); + +} +table.sortable td, table.sortable th { + border:1px solid rgb(180,180,180); + padding: 0px; +} +table.sortable, th, td { + border-collapse: collapse; +} +td { + background: var(--docked-drawer-background); +} +th { + white-space: nowrap; + font-weight: normal; +} +table.sortable thead tr .headerSortUp { + background-image: url("asc.gif"); +} +table.sortable thead tr .headerSortDown { + background-image: url("desc.gif"); +} +.searchable-header { + border-left:1px solid #fff; + border-right:1px solid #fff; + overflow:hidden; + padding:10px; +} +div.xmenu { + border: 1px solid; + background: linear-gradient(to bottom, rgb(88, 88, 100) 0%,rgb(121, 121, 170) 36%,rgb(141, 141, 181) 87%,rgb(150, 150, 190) 100%); + font-size: 0.8em; + position: absolute; + left: 100px; + top: 250px; +} +div.xmenu ul { + padding: 3px 6px 3px 3px; + margin-top: 0px; + margin-bottom: 0px; +} +div.xmenu input { + font-size: 1.0em; +} diff --git a/lib/web/htdocs/modules/table/table.js b/lib/web/htdocs/modules/table/table.js new file mode 100644 index 0000000000000000000000000000000000000000..156fbcd921cfa1db4f0de422c9ebe1f2c77fd1d7 --- /dev/null +++ b/lib/web/htdocs/modules/table/table.js @@ -0,0 +1,74 @@ +$(document).ready(setTimeout(function() { + $('table.sortable th img.sortit').each(function() { + + $(this).click(function() { + if ( $(this).hasClass("sortnone") ) { + newDirection = 'sortasc'; + } else if ( $(this).hasClass("sortasc") ) { + newDirection = 'sortdesc'; + } else if ( $(this).hasClass("sortdesc") ) { + newDirection = 'sortnone'; + } else { + newDirection = 'sortasc'; + } + var text = getCellText($(this)); + $('input[name=sort_col]').val(text) + $('input[name=sort_dir]').val(newDirection) + $('form:first').submit() + $('input[name=sort_col]').val(null) + $('input[name=sort_dir]').val(null) + }); + }); + + $('table.sortable th img.filterit').each(function() { + $(this).click(function() { + var text = getCellText($(this)); + var outerWidth = 0; + var innerWidth = 0; + var popupElem; + var isVisible = $('div#'+text+'-menu').is(':visible'); + $('div[id$=-menu]').each(function() { + $(this).hide(); + }); + $('div#'+text+'-menu').each(function() { + if ( !isVisible ) { + $(this).show(); + } + outerWidth = $(this).outerWidth(); + innerWidth = $(this).innerWidth(); + popupElem = $(this); + }); + var $left = $(this).offset().left - $('table.sortable').offset().left - $('#tablecontent').scrollLeft(); + var $farleft = $('table.sortable').width() - outerWidth -2 - $('#tablecontent').scrollLeft(); + var $width = innerWidth; + if ($left > $farleft) { + $left = $farleft; + } + var $bottom = $(this).offset().top + $(this).outerHeight() + 1 +$('.scrollFrameY').scrollTop(); + popupElem.css({ + left: $left, + top: $bottom, + width: $width, + }); + }); + }); + + $('table.sortable th input[type=checkbox]').each(function() { + $(this).change(function() { + var id = $(this).attr('id'); + var checked = $(this).is(':checked'); + $('table.sortable tr:not([class$="-hide"]) td input.'+id+':checkbox').each(function() { + $(this).prop('checked', checked); + }); + }); + }); + + function getCellText(elem) { + text = elem.parent().text(); + if (!text) { + text = elem.parent().parent().find("input").attr("id"); + } + return text + } + +}, 1000)); diff --git a/lib/web/htdocs/modules/tabs/__init__.py b/lib/web/htdocs/modules/tabs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/tabs/icons/__init__.py b/lib/web/htdocs/modules/tabs/icons/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/tabs/icons/abort.png b/lib/web/htdocs/modules/tabs/icons/abort.png new file mode 100644 index 0000000000000000000000000000000000000000..f0098916003f8e77d8cd31d3c1d494be4b4333e8 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/abort.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/accept.png b/lib/web/htdocs/modules/tabs/icons/accept.png new file mode 100644 index 0000000000000000000000000000000000000000..89c8129a490b329f3165f32fa0781701aab417ea Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/accept.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/acl.png b/lib/web/htdocs/modules/tabs/icons/acl.png new file mode 100644 index 0000000000000000000000000000000000000000..e1c21b34282fcd2a06f3f55cf17e931018585353 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/acl.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/add.png b/lib/web/htdocs/modules/tabs/icons/add.png new file mode 100644 index 0000000000000000000000000000000000000000..6332fefea4be19eeadf211b0b202b272e8564898 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/add.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/application_form.png b/lib/web/htdocs/modules/tabs/icons/application_form.png new file mode 100644 index 0000000000000000000000000000000000000000..807b862cfc087b70dcdd971af3ac92688484e998 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/application_form.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/arrow_branch.png b/lib/web/htdocs/modules/tabs/icons/arrow_branch.png new file mode 100644 index 0000000000000000000000000000000000000000..7542db1d1a57f9f3ccc6d5d4dc82ddada796e67f Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/arrow_branch.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/arrow_down.png b/lib/web/htdocs/modules/tabs/icons/arrow_down.png new file mode 100644 index 0000000000000000000000000000000000000000..2c4e279377bf348f9cf53894e76bb673ccf067bd Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/arrow_down.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/arrow_join.png b/lib/web/htdocs/modules/tabs/icons/arrow_join.png new file mode 100644 index 0000000000000000000000000000000000000000..a128413d8892dede67a722b755a0e5a241e22cef Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/arrow_join.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/arrow_left.png b/lib/web/htdocs/modules/tabs/icons/arrow_left.png new file mode 100644 index 0000000000000000000000000000000000000000..5dc696781e6135d37b5bf2e98e46fd94f020c48d Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/arrow_left.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/arrow_out.png b/lib/web/htdocs/modules/tabs/icons/arrow_out.png new file mode 100644 index 0000000000000000000000000000000000000000..2e9bc42bec16e3077a9680e7af0f90395bfeb60c Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/arrow_out.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/arrow_right.png b/lib/web/htdocs/modules/tabs/icons/arrow_right.png new file mode 100644 index 0000000000000000000000000000000000000000..b1a1819238c6de8f9e50988f4151261fa6ba64ea Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/arrow_right.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/arrow_switch.png b/lib/web/htdocs/modules/tabs/icons/arrow_switch.png new file mode 100644 index 0000000000000000000000000000000000000000..258c16c63a20f7474764507475af7961ecf4263a Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/arrow_switch.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/arrow_up.png b/lib/web/htdocs/modules/tabs/icons/arrow_up.png new file mode 100644 index 0000000000000000000000000000000000000000..1ebb193243780b8eb1919a51ef27c2a0d36ccec2 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/arrow_up.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/auto_rec.png b/lib/web/htdocs/modules/tabs/icons/auto_rec.png new file mode 100644 index 0000000000000000000000000000000000000000..ba7aecd8ccba0b5b41afe7426b1a8dc529b6760d Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/auto_rec.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/award_star_bronze_3.png b/lib/web/htdocs/modules/tabs/icons/award_star_bronze_3.png new file mode 100644 index 0000000000000000000000000000000000000000..396e4b3a2583924c56773430b5f2a5de992bb696 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/award_star_bronze_3.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/award_star_gold_3.png b/lib/web/htdocs/modules/tabs/icons/award_star_gold_3.png new file mode 100644 index 0000000000000000000000000000000000000000..124c9914f31992fcc7461d3682516aa5b065a89e Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/award_star_gold_3.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/award_star_silver_3.png b/lib/web/htdocs/modules/tabs/icons/award_star_silver_3.png new file mode 100644 index 0000000000000000000000000000000000000000..1d72d47247ceaddb46f918d53ec4444d633912f6 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/award_star_silver_3.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/baseconf.png b/lib/web/htdocs/modules/tabs/icons/baseconf.png new file mode 100644 index 0000000000000000000000000000000000000000..db4085d56dbac686ed1401c6715329eb8cdc37bf Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/baseconf.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/bell.png b/lib/web/htdocs/modules/tabs/icons/bell.png new file mode 100644 index 0000000000000000000000000000000000000000..6e0015df4f737ded7e7e14b546616e704f023226 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/bell.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/bouquets.png b/lib/web/htdocs/modules/tabs/icons/bouquets.png new file mode 100644 index 0000000000000000000000000000000000000000..ee21ba0c75a7fc428ffa12b54d6f16b0abc6a50f Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/bouquets.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/brick.png b/lib/web/htdocs/modules/tabs/icons/brick.png new file mode 100644 index 0000000000000000000000000000000000000000..7851cf34c946e5667221e3478668503eb1cd733f Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/brick.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/broadcast_details.png b/lib/web/htdocs/modules/tabs/icons/broadcast_details.png new file mode 100644 index 0000000000000000000000000000000000000000..e0b2fb78442a7e315cd3f4dae43630fffe9a82fb Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/broadcast_details.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/cancel.png b/lib/web/htdocs/modules/tabs/icons/cancel.png new file mode 100644 index 0000000000000000000000000000000000000000..c149c2bc017d5ce5a8ae9330dd7dbd012482e0f4 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/cancel.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/cancel_button.png b/lib/web/htdocs/modules/tabs/icons/cancel_button.png new file mode 100644 index 0000000000000000000000000000000000000000..535d9871c59ea5883c143a7773b5b34ccf4e0b4c Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/cancel_button.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/channel_tags.png b/lib/web/htdocs/modules/tabs/icons/channel_tags.png new file mode 100644 index 0000000000000000000000000000000000000000..74087dce2be0487b7f5adcc34b95efe4c80d4453 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/channel_tags.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/channels.png b/lib/web/htdocs/modules/tabs/icons/channels.png new file mode 100644 index 0000000000000000000000000000000000000000..900b98b029bcc379bd7dd9f74ce7f3cc45f04237 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/channels.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/clean.png b/lib/web/htdocs/modules/tabs/icons/clean.png new file mode 100644 index 0000000000000000000000000000000000000000..d9f914aad343d7be50c80dc5e45ff427ed60796f Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/clean.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/clock.png b/lib/web/htdocs/modules/tabs/icons/clock.png new file mode 100644 index 0000000000000000000000000000000000000000..e2672c20676177efb2fdea593b8f000fd5f12342 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/clock.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/connections.png b/lib/web/htdocs/modules/tabs/icons/connections.png new file mode 100644 index 0000000000000000000000000000000000000000..b335cb11c4d1a397b307883adcfe1e00c4cf8e6a Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/connections.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/control_pause.png b/lib/web/htdocs/modules/tabs/icons/control_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..0b106fc94809537751bfb06cdf236251427cbd8f Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/control_pause.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/control_play.png b/lib/web/htdocs/modules/tabs/icons/control_play.png new file mode 100644 index 0000000000000000000000000000000000000000..40c53fbbb6f05db43159b5ba3cdecdbd6ebb8acf Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/control_play.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/control_stop.png b/lib/web/htdocs/modules/tabs/icons/control_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..2bbb1f1cc3608b6ff6281d4b138ddd055db65abb Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/control_stop.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/debug.png b/lib/web/htdocs/modules/tabs/icons/debug.png new file mode 100644 index 0000000000000000000000000000000000000000..21b5813d676ee4f4587f4e30397c2b221c16aab5 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/debug.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/delete.png b/lib/web/htdocs/modules/tabs/icons/delete.png new file mode 100644 index 0000000000000000000000000000000000000000..08f249365afd29594b51210c6e21ba253897505d Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/delete.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/download.png b/lib/web/htdocs/modules/tabs/icons/download.png new file mode 100644 index 0000000000000000000000000000000000000000..f0e6ceb5b77286455a5ab7373b53ac655bed5eb6 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/download.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/drive.png b/lib/web/htdocs/modules/tabs/icons/drive.png new file mode 100644 index 0000000000000000000000000000000000000000..37b7c9b27d39acaaecf06951b024ac08afbfd4d2 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/drive.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/dvr.png b/lib/web/htdocs/modules/tabs/icons/dvr.png new file mode 100644 index 0000000000000000000000000000000000000000..8265724ee4693e9419b523b26bbd37b7e0245144 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/dvr.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/dvrprofiles.png b/lib/web/htdocs/modules/tabs/icons/dvrprofiles.png new file mode 100644 index 0000000000000000000000000000000000000000..722d8c5cf6098e4ac6143a9919aca75fe68230a2 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/dvrprofiles.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/edit.png b/lib/web/htdocs/modules/tabs/icons/edit.png new file mode 100644 index 0000000000000000000000000000000000000000..046811ed7a6ef16be1a54bb860e1f22c6dacdacf Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/edit.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/epg.png b/lib/web/htdocs/modules/tabs/icons/epg.png new file mode 100644 index 0000000000000000000000000000000000000000..d360b16248d8423e21d9bf698a57c69ca2f3e038 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/epg.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/exclamation.png b/lib/web/htdocs/modules/tabs/icons/exclamation.png new file mode 100644 index 0000000000000000000000000000000000000000..c37bd062e60c3b38fc82e4d1f236a8ac2fae9d8c Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/exclamation.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/eye.png b/lib/web/htdocs/modules/tabs/icons/eye.png new file mode 100644 index 0000000000000000000000000000000000000000..564a1a9714ff37aee1c8758109113e434eff7862 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/eye.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/fetch_images.png b/lib/web/htdocs/modules/tabs/icons/fetch_images.png new file mode 100644 index 0000000000000000000000000000000000000000..ffbeb06df4db1676486e937949732a932a86d67a Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/fetch_images.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/film.png b/lib/web/htdocs/modules/tabs/icons/film.png new file mode 100644 index 0000000000000000000000000000000000000000..b0ce7bb198a3b268bd634d2b26e9b710f3797d37 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/film.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/film_edit.png b/lib/web/htdocs/modules/tabs/icons/film_edit.png new file mode 100644 index 0000000000000000000000000000000000000000..af66b73f2097d34f01d86c6a4a4d7f1980f94dd8 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/film_edit.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/film_key.png b/lib/web/htdocs/modules/tabs/icons/film_key.png new file mode 100644 index 0000000000000000000000000000000000000000..58921624ea7ad94f49d7503917298b534f9dba65 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/film_key.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/find.png b/lib/web/htdocs/modules/tabs/icons/find.png new file mode 100644 index 0000000000000000000000000000000000000000..1547479646722bda4647df52cf3e8bc9b77428c6 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/find.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/full_screen.png b/lib/web/htdocs/modules/tabs/icons/full_screen.png new file mode 100644 index 0000000000000000000000000000000000000000..38225a44970b2d021f7f04dbf35ee3195a5aa181 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/full_screen.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/general.png b/lib/web/htdocs/modules/tabs/icons/general.png new file mode 100644 index 0000000000000000000000000000000000000000..0c9faff1818d93d862d624a52e3a5347cd4d4bcf Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/general.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/group.png b/lib/web/htdocs/modules/tabs/icons/group.png new file mode 100644 index 0000000000000000000000000000000000000000..7fb4e1f1e1cd6ee67d33ffd24f09ddd5c3478bec Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/group.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/help.png b/lib/web/htdocs/modules/tabs/icons/help.png new file mode 100644 index 0000000000000000000000000000000000000000..944d273ddd354724cd3d47d1ffd81605dc46c36a Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/help.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/imdb.png b/lib/web/htdocs/modules/tabs/icons/imdb.png new file mode 100644 index 0000000000000000000000000000000000000000..6c9c254fb639e0fb4a3daa2dce158f2a59f69fcc Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/imdb.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/imgcacheconf.png b/lib/web/htdocs/modules/tabs/icons/imgcacheconf.png new file mode 100644 index 0000000000000000000000000000000000000000..5e9304450d5ba980f2a65bdce177768e61307845 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/imgcacheconf.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/information.png b/lib/web/htdocs/modules/tabs/icons/information.png new file mode 100644 index 0000000000000000000000000000000000000000..12cd1aef900803abba99b26920337ec01ad5c267 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/information.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/ip_block.png b/lib/web/htdocs/modules/tabs/icons/ip_block.png new file mode 100644 index 0000000000000000000000000000000000000000..5467ecd5433bdea118fa54d325f7a75bebf31823 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/ip_block.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/key.png b/lib/web/htdocs/modules/tabs/icons/key.png new file mode 100644 index 0000000000000000000000000000000000000000..4ec1a928140311ff30a0a9120e958096c77f446e Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/key.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/layers.png b/lib/web/htdocs/modules/tabs/icons/layers.png new file mode 100644 index 0000000000000000000000000000000000000000..00818f63635ef3b3c04260c0d8f160b19570cb62 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/layers.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/linked.gif b/lib/web/htdocs/modules/tabs/icons/linked.gif new file mode 100644 index 0000000000000000000000000000000000000000..f1eeedf75745d43cf4ae3109b527f555648343b4 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/linked.gif differ diff --git a/lib/web/htdocs/modules/tabs/icons/mux_schedulers.png b/lib/web/htdocs/modules/tabs/icons/mux_schedulers.png new file mode 100644 index 0000000000000000000000000000000000000000..a693c8b4d0c904c3a66c57ac26d4bd9629ddc203 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/mux_schedulers.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/muxes.png b/lib/web/htdocs/modules/tabs/icons/muxes.png new file mode 100644 index 0000000000000000000000000000000000000000..b709029ad83f39ec32564dde8255a8a26902cf07 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/muxes.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/networks.png b/lib/web/htdocs/modules/tabs/icons/networks.png new file mode 100644 index 0000000000000000000000000000000000000000..cd3e2b9e500e8d3aed6bf0f73fdb21f3fba8b2c1 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/networks.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/newspaper.png b/lib/web/htdocs/modules/tabs/icons/newspaper.png new file mode 100644 index 0000000000000000000000000000000000000000..6a2ecce1b85eaa9084b427ee2c5226e2296eaeb8 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/newspaper.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/other_filters.png b/lib/web/htdocs/modules/tabs/icons/other_filters.png new file mode 100644 index 0000000000000000000000000000000000000000..f54db046d8b10587abfc0e79eeafe2ee61f19550 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/other_filters.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/pass.png b/lib/web/htdocs/modules/tabs/icons/pass.png new file mode 100644 index 0000000000000000000000000000000000000000..1f6f8428dd44a4bc4ff3ff85a2e6d95017995761 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/pass.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/pci.png b/lib/web/htdocs/modules/tabs/icons/pci.png new file mode 100644 index 0000000000000000000000000000000000000000..5bc6f22a82a37fcc45c19d8a904c42f200ecc79d Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/pci.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/plugin.png b/lib/web/htdocs/modules/tabs/icons/plugin.png new file mode 100644 index 0000000000000000000000000000000000000000..6187b15aec001b7080b51a5f944f07591f26cc15 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/plugin.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/progress-bg-green.gif b/lib/web/htdocs/modules/tabs/icons/progress-bg-green.gif new file mode 100644 index 0000000000000000000000000000000000000000..ab2704c7d93ed485623d5ce1ca78e9602d72ec7d Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/progress-bg-green.gif differ diff --git a/lib/web/htdocs/modules/tabs/icons/progress-bg-orange.gif b/lib/web/htdocs/modules/tabs/icons/progress-bg-orange.gif new file mode 100644 index 0000000000000000000000000000000000000000..edd099eaf4e842eee9a41323d6e11183eb94518b Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/progress-bg-orange.gif differ diff --git a/lib/web/htdocs/modules/tabs/icons/progress-bg-red.gif b/lib/web/htdocs/modules/tabs/icons/progress-bg-red.gif new file mode 100644 index 0000000000000000000000000000000000000000..e5750550aa3cc362d34eead4e823b39e2c5fc5e6 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/progress-bg-red.gif differ diff --git a/lib/web/htdocs/modules/tabs/icons/rec.png b/lib/web/htdocs/modules/tabs/icons/rec.png new file mode 100644 index 0000000000000000000000000000000000000000..10db3ac5a687c0ee195a6620c6ca25edbaf6f572 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/rec.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/recordingtab.png b/lib/web/htdocs/modules/tabs/icons/recordingtab.png new file mode 100644 index 0000000000000000000000000000000000000000..b6c5132de594d1e3bdc303cc0f37928f2943653f Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/recordingtab.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/reseticon.png b/lib/web/htdocs/modules/tabs/icons/reseticon.png new file mode 100644 index 0000000000000000000000000000000000000000..09a1c6cd24644dc6c6e900f990cbca9df793b9d3 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/reseticon.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/satipsrvconf.png b/lib/web/htdocs/modules/tabs/icons/satipsrvconf.png new file mode 100644 index 0000000000000000000000000000000000000000..649bd88b5c426f8f27d72432a01aa44ee0f7319f Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/satipsrvconf.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/save.png b/lib/web/htdocs/modules/tabs/icons/save.png new file mode 100644 index 0000000000000000000000000000000000000000..caea546af549a0302848f4f478c5bd4aae15bc01 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/save.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/scheduled.png b/lib/web/htdocs/modules/tabs/icons/scheduled.png new file mode 100644 index 0000000000000000000000000000000000000000..6705dd9c7afc221b3b623a0863053667c9883a31 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/scheduled.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/service_mapper.png b/lib/web/htdocs/modules/tabs/icons/service_mapper.png new file mode 100644 index 0000000000000000000000000000000000000000..45f10a415314d1880ef42470ed00e3a1968cf7ca Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/service_mapper.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/services.png b/lib/web/htdocs/modules/tabs/icons/services.png new file mode 100644 index 0000000000000000000000000000000000000000..756975ca73f57aa532706bdb7194d01468d106ee Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/services.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/sound.png b/lib/web/htdocs/modules/tabs/icons/sound.png new file mode 100644 index 0000000000000000000000000000000000000000..6056d234a9818d248987389d4a621e5c83ce0851 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/sound.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/sound_mute.png b/lib/web/htdocs/modules/tabs/icons/sound_mute.png new file mode 100644 index 0000000000000000000000000000000000000000..b652d2a71fc0e866d855c08f415b7ec057b3cee9 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/sound_mute.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/stop.png b/lib/web/htdocs/modules/tabs/icons/stop.png new file mode 100644 index 0000000000000000000000000000000000000000..0cfd585963d255190b8855a7689e8da1c4d7cf6b Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/stop.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/stop_rec.png b/lib/web/htdocs/modules/tabs/icons/stop_rec.png new file mode 100644 index 0000000000000000000000000000000000000000..87219a49db0827045a3056ea0c6ef8314bace6ed Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/stop_rec.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/stream.png b/lib/web/htdocs/modules/tabs/icons/stream.png new file mode 100644 index 0000000000000000000000000000000000000000..4191f42c46226f61361514994129bbc4f9546b8d Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/stream.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/subscriptions.png b/lib/web/htdocs/modules/tabs/icons/subscriptions.png new file mode 100644 index 0000000000000000000000000000000000000000..10137e55cada13d8591c84070a0998b4ae2c2bef Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/subscriptions.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/subtitle.png b/lib/web/htdocs/modules/tabs/icons/subtitle.png new file mode 100644 index 0000000000000000000000000000000000000000..b3ff4b9f8501c26c3d538f9748a7422e784d96d1 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/subtitle.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/tag.png b/lib/web/htdocs/modules/tabs/icons/tag.png new file mode 100644 index 0000000000000000000000000000000000000000..e093032a77d0b90d3a5dc05759dd6bcc2ad51715 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/tag.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/tag_blue.png b/lib/web/htdocs/modules/tabs/icons/tag_blue.png new file mode 100644 index 0000000000000000000000000000000000000000..9757fc6ed6597438eb8e5a70a1ab2402cdebd5d1 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/tag_blue.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/teletext.png b/lib/web/htdocs/modules/tabs/icons/teletext.png new file mode 100644 index 0000000000000000000000000000000000000000..e9642ccf8393bc41950ab5b672535a8ece3a28ed Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/teletext.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/television.png b/lib/web/htdocs/modules/tabs/icons/television.png new file mode 100644 index 0000000000000000000000000000000000000000..1738a4f1061e2d50eec37aaca71fc4506daf737b Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/television.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/thetvdb.png b/lib/web/htdocs/modules/tabs/icons/thetvdb.png new file mode 100644 index 0000000000000000000000000000000000000000..8b785502d036c1a3192ef3eb65342a13ab3d1aaa Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/thetvdb.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/tick.png b/lib/web/htdocs/modules/tabs/icons/tick.png new file mode 100644 index 0000000000000000000000000000000000000000..a9925a06ab02db30c1e7ead9c701c15bc63145cb Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/tick.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/time_schedules.png b/lib/web/htdocs/modules/tabs/icons/time_schedules.png new file mode 100644 index 0000000000000000000000000000000000000000..462783e8f5476a96422a1c5d113558a8be171278 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/time_schedules.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/timeshift.png b/lib/web/htdocs/modules/tabs/icons/timeshift.png new file mode 100644 index 0000000000000000000000000000000000000000..963280eb5ff971b255a79f077a81eed409f08403 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/timeshift.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/tv_cards.png b/lib/web/htdocs/modules/tabs/icons/tv_cards.png new file mode 100644 index 0000000000000000000000000000000000000000..76e6c2f8dcb0dd2f8b450287b14c497247757829 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/tv_cards.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/undo.png b/lib/web/htdocs/modules/tabs/icons/undo.png new file mode 100644 index 0000000000000000000000000000000000000000..6972c5e5946080ca1dec4de09d9430d3edf6c555 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/undo.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/upcoming_rec.png b/lib/web/htdocs/modules/tabs/icons/upcoming_rec.png new file mode 100644 index 0000000000000000000000000000000000000000..e6ba349d2be303fc65c682b80d9b14f10a2dc649 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/upcoming_rec.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/wand.png b/lib/web/htdocs/modules/tabs/icons/wand.png new file mode 100644 index 0000000000000000000000000000000000000000..44ccbf812879c42cb1f9587d865bcfc337ce6361 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/wand.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/watch_tv.png b/lib/web/htdocs/modules/tabs/icons/watch_tv.png new file mode 100644 index 0000000000000000000000000000000000000000..c6c64aa685687d0b71e220283720f61f7cc80c15 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/watch_tv.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/world.png b/lib/web/htdocs/modules/tabs/icons/world.png new file mode 100644 index 0000000000000000000000000000000000000000..68f21d30116710e48a8bf462cb32441e51fad5f6 Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/world.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/world_add.png b/lib/web/htdocs/modules/tabs/icons/world_add.png new file mode 100644 index 0000000000000000000000000000000000000000..6d0d7f74c0d89a5d1975eb65c3e048ace0290daf Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/world_add.png differ diff --git a/lib/web/htdocs/modules/tabs/icons/wrench.png b/lib/web/htdocs/modules/tabs/icons/wrench.png new file mode 100644 index 0000000000000000000000000000000000000000..5c8213fef5ab969f03189d4367e32e597e38bd7f Binary files /dev/null and b/lib/web/htdocs/modules/tabs/icons/wrench.png differ diff --git a/lib/web/htdocs/modules/tabs/tabs.css b/lib/web/htdocs/modules/tabs/tabs.css new file mode 100644 index 0000000000000000000000000000000000000000..6b3e792172d27ab6908ee3319c798fccfed4e411 --- /dev/null +++ b/lib/web/htdocs/modules/tabs/tabs.css @@ -0,0 +1,116 @@ +:root { + --tabs-top-padding: 0px; + --tabs-overhead-space: 8px; + --tabs-height: 1.5em; + --tabs-total-height: calc(var(--tabs-top-padding) + var(--tabs-height) + var(--tabs-overhead-space)); + --tabs-bottom-padding-active: var(--tabs-overhead-space); + --tabs-overhead-space-active: 0px; + --tabs-top-border-active: 2px; + --tabs-height-active: calc(var(--tabs-height) - var(--tabs-top-border-active)); + --tabs-width-padding: 5px; + --tabs-fontsize: 1.0em; + --tabs-tab-background: rgba(191, 63, 255, 0.6); + --tabs-text-color: var(--theme-text-color); + --tabs-hover-color: rgba(205, 128, 255, 1.0); + --tabs-border-top-color: rgba(150, 150, 150, 1.0); +} +body { + overflow-y: scroll; + background: rgb(18, 4, 139); +} + + +.tabIcon { + vertical-align: middle; + padding: 0px 0px 1px 0px; +} + + + +ul.tabs { + height: var(--tabs-total-height); + margin: 0 auto; + list-style: none; + display: inline-table; + overflow: hidden; + padding: 0; + font-size: var(--tabs-fontsize) +} +ul.tabs li { + float: left; +} +ul.tabs li a { + position: relative; + display: block; + height: var(--tabs-height); + margin-top: var(--tabs-overhead-space); + padding: var(--tabs-top-padding) var(--tabs-width-padding) 0 var(--tabs-width-padding); + text-align: center; + text-decoration: none; + color: var(--tabs-text-color); + background: var(--tabs-tab-background); + -webkit-box-shadow: inset 0px 7px 10px 0px rgba(0,40,40,0.2), inset 1px 2px 2px 0px rgb(200, 250, 250); + -moz-box-shadow: inset 0px 7px 10px 0px rgba(0,40,40,0.2), inset 1px 2px 2px 0px rgb(200, 250, 250); + -box-shadow: inset 0px 7px 10px 0px rgba(0,40,40,0.2), inset 1px 2px 2px 0px rgb(200, 250, 250); + border: 0px solid #000; + -webkit-transition: padding 0.2s ease, margin 0.2s ease; + -moz-transition: padding 0.2s ease, margin 0.2s ease; + -ms-transition: padding 0.2s ease, margin 0.2s ease; + -o-transition: padding 0.2s ease, margin 0.2s ease; + transition: padding 0.2s ease, margin 0.2s ease; +} + +.tabs li a { + -webkit-border-top-left-radius: 8px; + -moz-border-radius-topleft: 8px; + border-top-left-radius: 8px; + -webkit-border-top-right-radius: 8px; + -moz-border-radius-topright: 8px; + border-top-right-radius: 8px; +} +ul.tabs li a:hover { + margin: var(--tabs-overhead-space-active) 0 0 0; + padding: var(--tabs-top-padding) var(--tabs-width-padding) var(--tabs-bottom-padding-active) var(--tabs-width-padding); + background: var(--tabs-hover-color); + +} +ul.tabs li a.activeTab { + font-weight: bold; + margin: var(--tabs-overhead-space-active) 0 0 0; + padding: var(--tabs-top-padding) var(--tabs-width-padding) var(--tabs-bottom-padding-active) var(--tabs-width-padding); + color: var(--tabs-text-color); + height: var(--tabs-height-active); + background: var(--tabs-tab-background); + -webkit-box-shadow: inset 0px -20px 10px -15px rgba(0,40,40,0.2), inset -1px 5px 8px 0px rgb(200, 248, 248); + -moz-box-shadow: inset 0px -20px 10px -15px rgba(0,40,40,0.2), inset -1px 5px 8px 0px rgb(200, 248, 248); + -box-shadow: inset 0px -20px 10px -15px rgba(0,40,40,0.2), inset -1px 5px 8px 0px rgb(200, 248, 248); + + border-top: var(--tabs-top-border-active) solid var(--tabs-border-top-color); + border-right: 1px solid var(--tabs-border-top-color); + border-left: 1px solid var(--tabs-border-top-color); + z-index: 6; + outline: none; +} + + + + + +.content { + + margin: 0 auto; + background: rgba(98, 138, 131, 0.233); + -webkit-box-shadow: 2px 8px 25px -2px rgba(0,0,0,0.3); + -moz-box-shadow: 2px 8px 25px -2px rgba(0,0,0,0.3); + box-shadow: 2px 8px 25px -2px rgba(0,0,0,0.3); + -webkit-border-bottom-right-radius: 8px; + -webkit-border-bottom-left-radius: 8px; + -moz-border-radius-bottomright: 8px; + -moz-border-radius-bottomleft: 8px; + border-bottom-right-radius: 8px; + border-bottom-left-radius: 8px; + border-bottom: 2px solid #72655F; + border-right: 2px solid #72655F; + border-left: 2px solid #72655F; +} + diff --git a/lib/web/htdocs/modules/tabs/tabs.js b/lib/web/htdocs/modules/tabs/tabs.js new file mode 100644 index 0000000000000000000000000000000000000000..35de8a28b4b1acd230ba421d2444d539033e8359 --- /dev/null +++ b/lib/web/htdocs/modules/tabs/tabs.js @@ -0,0 +1,18 @@ +$(document).ready(function() { + $('.configTab').each(function() { + $(this).click(function() { + $('a.configTab').removeClass("activeTab"); + $(this).addClass("activeTab"); + $('form.sectionForm').hide(); + var section = $(this).attr("id").substring(3); + $("#form"+section).show(); + event.preventDefault(); + }); + }); + $('form.sectionForm').hide(); + var activeTab = $("a.activeTab").attr("id").substring(3); + $('#form'+activeTab).each(function() { + $(this).show(); + }); + +}); \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/LICENSE b/lib/web/htdocs/modules/themes/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..73176c58aba65836e1779699369b9aa8c41c3939 --- /dev/null +++ b/lib/web/htdocs/modules/themes/LICENSE @@ -0,0 +1,4 @@ +Creative Commons Attribution 4.0 License (CC-BY) + +Images for this application are licensed under a Creative Commons Attribution 4.0 License (CC-BY) +To view a copy of this license, visit https://creativecommons.org/licenses/by/4.0/legalcode diff --git a/lib/web/htdocs/modules/themes/__init__.py b/lib/web/htdocs/modules/themes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/appletv/__init__.py b/lib/web/htdocs/modules/themes/appletv/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/appletv/atv1-1080.png b/lib/web/htdocs/modules/themes/appletv/atv1-1080.png new file mode 100644 index 0000000000000000000000000000000000000000..70ffe5720bcd69f5d3cc077ea81bd6a725a98e20 --- /dev/null +++ b/lib/web/htdocs/modules/themes/appletv/atv1-1080.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0aa6e815a8775f9c335865befa953d8080780b40a25cc5d776223f92a4e65210 +size 270464 diff --git a/lib/web/htdocs/modules/themes/appletv/theme.css b/lib/web/htdocs/modules/themes/appletv/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..a12e761f3e74bcd42c336d24b81140819a2ad135 --- /dev/null +++ b/lib/web/htdocs/modules/themes/appletv/theme.css @@ -0,0 +1,477 @@ +:root { + --theme-primary-color: #52b54b; + --theme-text-color: rgba(0, 0, 0, 0.87); + --theme-text-color-opaque: #000; + --theme-accent-text-color: green; + --theme-primary-color-lightened: #5ec157; + --theme-icon-focus-background: rgba(82, 181, 75, 0.2); + --theme-background: #d5e9f2 url(atv1-1080.png) no-repeat center center; + --button-background: rgba(0, 0, 0, 0.1); + --card-background: rgba(0, 0, 0, 0.1); + --header-background: var(--theme-background); + --header-blur-background: linear-gradient(to right, rgba(188, 188, 188, 0.7), rgba(167, 180, 183, 0.7), rgba(190, 181, 165, 0.7), rgba(173, 190, 194, 0.7), rgba(185, 199, 203, 0.7)); + --footer-background: linear-gradient(to right, #bcbcbc, #a7b4b7, #beb5a5, #adbec2, #b9c7cb); + --footer-blur-background: linear-gradient(to right, rgba(188, 188, 188, 0.7), rgba(167, 180, 183, 0.7), rgba(190, 181, 165, 0.7), rgba(173, 190, 194, 0.7), rgba(185, 199, 203, 0.7)); + --theme-body-secondary-text-color: rgba(0, 0, 0, 0.6); + --line-background: rgba(0, 0, 0, 0.08); + --line-size: 0.13em; + --scrollbar-thumb-background: rgba(0, 0, 0, 0.2); + --drawer-background: #d5e9f2 url(atv1-1080.png) no-repeat center center; + --docked-drawer-background: rgba(255, 255, 255, 0.2); + --logo-url: /modules/themes/logodark.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +.formDialogFooter:not(.formDialogFooter-clear) { + border-top: var(--line-size) solid var(--line-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logodark.png); +} +.dialog, +html { + background: var(--theme-background); + -webkit-background-size: cover; + background-size: cover; +} +.backgroundContainer { + background: var(--theme-background); + -webkit-background-size: cover; + background-size: cover; +} +.backgroundContainer.withBackdrop { + background: -webkit-gradient( + linear, + left top, + left bottom, + from(rgba(192, 212, 222, 0.92)), + color-stop(rgba(235, 250, 254, 0.92)), + color-stop(rgba(227, 220, 212, 0.92)), + color-stop(rgba(206, 214, 216, 0.92)), + to(rgba(192, 211, 218, 0.92)) + ); + background: -webkit-linear-gradient(top, rgba(192, 212, 222, 0.92), rgba(235, 250, 254, 0.92), rgba(227, 220, 212, 0.92), rgba(206, 214, 216, 0.92), rgba(192, 211, 218, 0.92)); + background: -o-linear-gradient(top, rgba(192, 212, 222, 0.92), rgba(235, 250, 254, 0.92), rgba(227, 220, 212, 0.92), rgba(206, 214, 216, 0.92), rgba(192, 211, 218, 0.92)); + background: linear-gradient(to bottom, rgba(192, 212, 222, 0.92), rgba(235, 250, 254, 0.92), rgba(227, 220, 212, 0.92), rgba(206, 214, 216, 0.92), rgba(192, 211, 218, 0.92)); +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(0, 0, 0, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #000; + background: rgba(255, 255, 255, 0.7); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } +} +.fab:focus, +.raised:focus { + background: rgba(0, 0, 0, 0.24); +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.3); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #f8f8f8; + background: rgba(0, 0, 0, 0.1); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: rgba(0, 0, 0, 0.2); +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); + -webkit-background-size: cover; + background-size: cover; +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: rgba(0, 0, 0, 0.1); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: rgba(206, 214, 216, 0.92) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: rgba(206, 214, 216, 0.92) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + -webkit-box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: var(--card-background); +} +.defaultCardBackground2 { + background-color: var(--card-background); +} +.defaultCardBackground3 { + background-color: var(--card-background); +} +.defaultCardBackground4 { + background-color: var(--card-background); +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logodark.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/appletv/theme.js b/lib/web/htdocs/modules/themes/appletv/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/appletv/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/appletv/theme.json b/lib/web/htdocs/modules/themes/appletv/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..afefcdc0f337ac957d43f6e17b63a60e6b8a2f04 --- /dev/null +++ b/lib/web/htdocs/modules/themes/appletv/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#ADBEC2", + "androidStatusBarForegroundColor": "dark", + "androidNavigationBarForegroundColor": "dark" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/black/__init__.py b/lib/web/htdocs/modules/themes/black/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/black/theme.css b/lib/web/htdocs/modules/themes/black/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..ec62af8bed20d476f951c142dc32af3ccfe37447 --- /dev/null +++ b/lib/web/htdocs/modules/themes/black/theme.css @@ -0,0 +1,464 @@ +:root { + --theme-primary-color: #52b54b; + --theme-text-color: rgba(255, 255, 255, 0.87); + --theme-text-color-opaque: #fff; + --theme-accent-text-color: #52b54b; + --theme-primary-color-lightened: #5ec157; + --theme-icon-focus-background: rgba(82, 181, 75, 0.2); + --theme-background: #000; + --button-background: #222; + --card-background: #222; + --header-background: var(--theme-background); + --header-blur-background: rgba(0, 0, 0, 0.66); + --footer-background: #222; + --footer-blur-background: rgba(20, 20, 20, 0.66); + --theme-body-secondary-text-color: rgba(255, 255, 255, 0.6); + --line-background: rgba(255, 255, 255, 0.08); + --line-size: 0.08em; + --scrollbar-thumb-background: rgba(255, 255, 255, 0.3); + --drawer-background: #2c2c2e; + --docked-drawer-background: #1c1c1e; + --logo-url: /modules/themes/logowhite.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground.skinHeader-withfulldrawer { + border-bottom: var(--line-size) solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.backgroundContainer, +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer.withBackdrop { + background-color: rgba(0, 0, 0, 0.83); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(85, 85, 85, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #fff; + background: rgba(56, 56, 56, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + .toast-large { + color: rgba(255, 255, 255, 0.87); + } +} +.fab:focus, +.raised:focus { + background: #333; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(255, 255, 255, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #1c1c1c; + background: rgba(30, 30, 30, 0.9); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: #363636; +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid var(--button-background); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: #303030; + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #d2b019; +} +.defaultCardBackground2 { + background-color: #338abb; +} +.defaultCardBackground3 { + background-color: #6b689d; +} +.defaultCardBackground4 { + background-color: #dd452b; +} +.defaultCardBackground5 { + background-color: #5ccea9; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logowhite.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/black/theme.js b/lib/web/htdocs/modules/themes/black/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/black/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/black/theme.json b/lib/web/htdocs/modules/themes/black/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..a00b5b3042eac24d4cca1a24c2eda2cdab77c6ec --- /dev/null +++ b/lib/web/htdocs/modules/themes/black/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#000", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "light" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/blueradiance/__init__.py b/lib/web/htdocs/modules/themes/blueradiance/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/blueradiance/bg.jpg b/lib/web/htdocs/modules/themes/blueradiance/bg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8b7711d3717463c6e5ae876c416e000c45698ea5 --- /dev/null +++ b/lib/web/htdocs/modules/themes/blueradiance/bg.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32f5331a64000d73f6033a6f67e8c2d4b8395a090d972aabce289a9f81240d89 +size 1044963 diff --git a/lib/web/htdocs/modules/themes/blueradiance/theme.css b/lib/web/htdocs/modules/themes/blueradiance/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..67a0ad80531a49acc7fc18dbd86cf6a0ac4a7512 --- /dev/null +++ b/lib/web/htdocs/modules/themes/blueradiance/theme.css @@ -0,0 +1,470 @@ +:root { + --theme-primary-color: #52b54b; + --theme-text-color: rgba(255, 255, 255, 0.87); + --theme-text-color-opaque: #fff; + --theme-accent-text-color: #52b54b; + --theme-primary-color-lightened: #5ec157; + --theme-icon-focus-background: rgba(82, 181, 75, 0.2); + --theme-background: #033361; + --button-background: rgba(0, 0, 0, 0.5); + --card-background: rgba(0, 0, 0, 0.5); + --header-background: #033361 url(bg.jpg) no-repeat center top; + --header-blur-background: rgba(3, 51, 97, 0.7); + --footer-background: #033664; + --footer-blur-background: var(--footer-background); + --theme-body-secondary-text-color: rgba(255, 255, 255, 0.6); + --line-background: rgba(255, 255, 255, 0.08); + --line-size: 0.13em; + --scrollbar-thumb-background: #857371; + --drawer-background: #011432; + --docked-drawer-background: rgba(0, 0, 0, 0.2); + --logo-url: /modules/themes/logowhite.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground.skinHeader-withfulldrawer { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer { + background: var(--theme-background) url(bg.jpg) no-repeat center top; + -webkit-background-size: cover; + background-size: cover; +} +.backgroundContainer.withBackdrop { + opacity: 0.88; +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + opacity: 1; + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(0, 0, 0, 0.4); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #fff; + background: rgba(56, 56, 56, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + .toast-large { + color: rgba(255, 255, 255, 0.87); + } +} +.fab:focus, +.raised:focus { + background: rgba(0, 0, 0, 0.3); +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.3); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #1c1c1c; + background: rgba(30, 30, 30, 0.9); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: rgba(54, 54, 54, 0.8); +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: rgba(0, 0, 0, 0.5); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: rgba(13, 42, 86, 0.8) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: rgba(13, 42, 86, 0.8) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + -webkit-box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: var(--card-background); +} +.defaultCardBackground2 { + background-color: var(--card-background); +} +.defaultCardBackground3 { + background-color: var(--card-background); +} +.defaultCardBackground4 { + background-color: var(--card-background); +} +.defaultCardBackground5 { + background-color: var(--card-background); +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logowhite.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/blueradiance/theme.js b/lib/web/htdocs/modules/themes/blueradiance/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/blueradiance/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/blueradiance/theme.json b/lib/web/htdocs/modules/themes/blueradiance/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..a208fa72d3c0a837149e7916bf4cf3b4738190d3 --- /dev/null +++ b/lib/web/htdocs/modules/themes/blueradiance/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#011432", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "light" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/dark-red/__init__.py b/lib/web/htdocs/modules/themes/dark-red/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/dark-red/theme.css b/lib/web/htdocs/modules/themes/dark-red/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..4b296e2d3b3641b0544797403112e287828d9f5f --- /dev/null +++ b/lib/web/htdocs/modules/themes/dark-red/theme.css @@ -0,0 +1,486 @@ +:root { + --theme-primary-color: #cc3333; + --theme-text-color: rgba(255, 255, 255, 0.87); + --theme-text-color-opaque: #fff; + --theme-accent-text-color: #cc3333; + --theme-primary-color-lightened: #cc3333; + --theme-icon-focus-background: rgba(204, 51, 51, 0.2); + --theme-background: #141414; + --button-background: #242424; + --card-background: #242424; + --header-background: var(--theme-background); + --header-blur-background: rgba(20, 20, 20, 0.66); + --footer-background: #1d1d1d; + --footer-blur-background: rgba(29, 29, 31, 0.66); + --theme-body-secondary-text-color: rgba(255, 255, 255, 0.6); + --line-background: rgba(255, 255, 255, 0.08); + --line-size: 0.08em; + --scrollbar-thumb-background: rgba(255, 255, 255, 0.3); + --drawer-background: #2c2c2e; + --docked-drawer-background: #1c1c1e; + --logo-url: modules/themes/logowhite.png; +} +@media (pointer: fine) { + :not(.layout-tv):root { + --theme-background: #1f1f1f; + --header-blur-background: rgba(31, 31, 31, 0.66); + --drawer-background: #262626; + --docked-drawer-background: #262626; + --button-background: #2c2c2c; + --card-background: #2c2c2c; + --footer-background: #282828; + --footer-blur-background: rgba(40, 40, 40, 0.66); + } +} +.layout-tv:root { + --theme-background: #1a1a1a; + --header-blur-background: rgba(26, 26, 26, 0.66); + --drawer-background: #262626; + --docked-drawer-background: #262626; + --button-background: #2c2c2c; + --card-background: #2c2c2c; + --footer-background: #282828; + --footer-blur-background: rgba(40, 40, 40, 0.66); +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground.skinHeader-withfulldrawer { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.backgroundContainer, +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer.withBackdrop { + background-color: rgba(0, 0, 0, 0.83); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(85, 85, 85, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #fff; + background: rgba(56, 56, 56, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + .toast-large { + color: rgba(255, 255, 255, 0.87); + } +} +.fab:focus, +.raised:focus { + background: #333; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(255, 255, 255, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #1c1c1c; + background: rgba(30, 30, 30, 0.9); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: rgba(54, 54, 54, 0.8); +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid var(--button-background); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: #303030; + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #d2b019; +} +.defaultCardBackground2 { + background-color: #338abb; +} +.defaultCardBackground3 { + background-color: #6b689d; +} +.defaultCardBackground4 { + background-color: #dd452b; +} +.defaultCardBackground5 { + background-color: #5ccea9; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logowhite.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/dark-red/theme.js b/lib/web/htdocs/modules/themes/dark-red/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/dark-red/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/dark-red/theme.json b/lib/web/htdocs/modules/themes/dark-red/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..0e165f0779c1abab9992fb94eea9ecfb927589af --- /dev/null +++ b/lib/web/htdocs/modules/themes/dark-red/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#141414", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "light" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/dark/__init__.py b/lib/web/htdocs/modules/themes/dark/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/dark/theme.css b/lib/web/htdocs/modules/themes/dark/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..2282352841b5260475633d68ad69df04ec664d4f --- /dev/null +++ b/lib/web/htdocs/modules/themes/dark/theme.css @@ -0,0 +1,486 @@ +:root { + --theme-primary-color: #52b54b; + --theme-text-color: rgba(255, 255, 255, 0.87); + --theme-text-color-opaque: #fff; + --theme-accent-text-color: #52b54b; + --theme-primary-color-lightened: #5ec157; + --theme-icon-focus-background: rgba(82, 181, 75, 0.2); + --theme-background: #141414; + --button-background: #242424; + --card-background: #242424; + --header-background: var(--theme-background); + --header-blur-background: rgba(20, 20, 20, 0.66); + --footer-background: #1d1d1d; + --footer-blur-background: rgba(29, 29, 31, 0.66); + --theme-body-secondary-text-color: rgba(255, 255, 255, 0.6); + --line-background: rgba(255, 255, 255, 0.08); + --line-size: 0.08em; + --scrollbar-thumb-background: rgba(255, 255, 255, 0.3); + --drawer-background: #2c2c2e; + --docked-drawer-background: #1c1c1e; + --logo-url: /modules/themes/logowhite.png; +} +@media (pointer: fine) { + :not(.layout-tv):root { + --theme-background: #1f1f1f; + --header-blur-background: rgba(31, 31, 31, 0.66); + --drawer-background: #262626; + --docked-drawer-background: #262626; + --button-background: #2c2c2c; + --card-background: #2c2c2c; + --footer-background: #282828; + --footer-blur-background: rgba(40, 40, 40, 0.66); + } +} +.layout-tv:root { + --theme-background: #1a1a1a; + --header-blur-background: rgba(26, 26, 26, 0.66); + --drawer-background: #262626; + --docked-drawer-background: #262626; + --button-background: #2c2c2c; + --card-background: #2c2c2c; + --footer-background: #282828; + --footer-blur-background: rgba(40, 40, 40, 0.66); +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground.skinHeader-withfulldrawer { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.backgroundContainer, +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer.withBackdrop { + background-color: rgba(0, 0, 0, 0.83); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(85, 85, 85, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #fff; + background: rgba(56, 56, 56, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + .toast-large { + color: rgba(255, 255, 255, 0.87); + } +} +.fab:focus, +.raised:focus { + background: #333; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(255, 255, 255, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #1c1c1c; + background: rgba(30, 30, 30, 0.9); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: rgba(54, 54, 54, 0.8); +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid var(--button-background); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: #303030; + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #d2b019; +} +.defaultCardBackground2 { + background-color: #338abb; +} +.defaultCardBackground3 { + background-color: #6b689d; +} +.defaultCardBackground4 { + background-color: #dd452b; +} +.defaultCardBackground5 { + background-color: #5ccea9; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logowhite.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/dark/theme.js b/lib/web/htdocs/modules/themes/dark/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/dark/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/dark/theme.json b/lib/web/htdocs/modules/themes/dark/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..0e165f0779c1abab9992fb94eea9ecfb927589af --- /dev/null +++ b/lib/web/htdocs/modules/themes/dark/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#141414", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "light" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/halloween/__init__.py b/lib/web/htdocs/modules/themes/halloween/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/halloween/bg.jpg b/lib/web/htdocs/modules/themes/halloween/bg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d95e01dc04441d8a6da2e8b804c643ee667f1080 --- /dev/null +++ b/lib/web/htdocs/modules/themes/halloween/bg.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c9305d4951fefbefb4b91228b527b95f64581cd8eb94524050ce13e46964736 +size 136376 diff --git a/lib/web/htdocs/modules/themes/halloween/theme.css b/lib/web/htdocs/modules/themes/halloween/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..194c17a03fc4996a2321b0e9c748184835c059dd --- /dev/null +++ b/lib/web/htdocs/modules/themes/halloween/theme.css @@ -0,0 +1,478 @@ +:root { + --theme-primary-color: #ff9100; + --theme-text-color: rgba(255, 255, 255, 0.87); + --theme-text-color-opaque: #fff; + --theme-accent-text-color: #ff9100; + --theme-primary-color-lightened: #ff9100; + --theme-icon-focus-background: rgba(255, 145, 0, 0.2); + --theme-background: rgba(20, 20, 20, 0.8); + --button-background: #242424; + --card-background: #242424; + --header-background: #141414 url(bg.jpg) no-repeat center top; + --header-blur-background: rgba(20, 20, 20, 0.66); + --footer-background: #1d1d1d; + --footer-blur-background: rgba(29, 29, 31, 0.66); + --theme-body-secondary-text-color: rgba(255, 255, 255, 0.6); + --line-background: rgba(255, 255, 255, 0.08); + --line-size: 0.08em; + --scrollbar-thumb-background: rgba(255, 255, 255, 0.3); + --drawer-background: rgba(44, 44, 46, 1.0); + --docked-drawer-background: rgba(28, 28, 30, 0.5); + --logo-url: /modules/themes/logowhite.png; +} +.layout-tv:root { + --theme-background: #1a1a1a; + --header-blur-background: rgba(26, 26, 26, 0.66); + --drawer-background: #262626; + --docked-drawer-background: #262626; + --button-background: #2c2c2c; + --card-background: #2c2c2c; + --footer-background: #282828; + --footer-blur-background: rgba(40, 40, 40, 0.66); +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground.skinHeader-withfulldrawer { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer { + background: linear-gradient( var(--theme-background), var(--theme-background) ), url(bg.jpg), no-repeat center top; + -webkit-background-size: cover; + background-size: cover; +} +.backgroundContainer.withBackdrop { + background-color: rgba(0, 0, 0, 0.83); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(85, 85, 85, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #fff; + background: rgba(56, 56, 56, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + .toast-large { + color: rgba(255, 255, 255, 0.87); + } +} +.fab:focus, +.raised:focus { + background: #333; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(255, 255, 255, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #1c1c1c; + background: rgba(30, 30, 30, 0.9); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: rgba(54, 54, 54, 0.8); +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid var(--button-background); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: #303030; + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #d2b019; +} +.defaultCardBackground2 { + background-color: #338abb; +} +.defaultCardBackground3 { + background-color: #6b689d; +} +.defaultCardBackground4 { + background-color: #dd452b; +} +.defaultCardBackground5 { + background-color: #5ccea9; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logowhite.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/halloween/theme.js b/lib/web/htdocs/modules/themes/halloween/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/halloween/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/halloween/theme.json b/lib/web/htdocs/modules/themes/halloween/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..6edace1c6c34ad87e320e5c2743e22060a6012cf --- /dev/null +++ b/lib/web/htdocs/modules/themes/halloween/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#282828", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "light" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/holiday/__init__.py b/lib/web/htdocs/modules/themes/holiday/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/holiday/bg.jpg b/lib/web/htdocs/modules/themes/holiday/bg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..700d29b91fa5299bb34ccd882a65bc7fc61ff5f2 --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/bg.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1fa8b1c9ff02c31fcdb97f738df3ee24bcaf407913e6c420a2fba40b60749ed +size 164973 diff --git a/lib/web/htdocs/modules/themes/holiday/bg1.jpg b/lib/web/htdocs/modules/themes/holiday/bg1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b059079743ed6267805150ad79a90d09758aa9f8 --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/bg1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb995fb7a53ccf96f742899c428ccdc948b4728e8b25bbc225472577b744eeb6 +size 945036 diff --git a/lib/web/htdocs/modules/themes/holiday/bg2.jpg b/lib/web/htdocs/modules/themes/holiday/bg2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3357c4d4a14ae68bffb7ffcd19bbffa11f97131f --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/bg2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b63893fddf2be3356937ec80e1e6ac68eae17675ada3e65932301283dcbac14 +size 256775 diff --git a/lib/web/htdocs/modules/themes/holiday/bg3.jpg b/lib/web/htdocs/modules/themes/holiday/bg3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..700d29b91fa5299bb34ccd882a65bc7fc61ff5f2 --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/bg3.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1fa8b1c9ff02c31fcdb97f738df3ee24bcaf407913e6c420a2fba40b60749ed +size 164973 diff --git a/lib/web/htdocs/modules/themes/holiday/bg4.jpg b/lib/web/htdocs/modules/themes/holiday/bg4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..59afcac9635c4fce267475e24bc422947f0d9b5b --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/bg4.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e75bff48a61181c679e5f7a7ce8677857921dfb8286711ba4e47815e5cc1c297 +size 499159 diff --git a/lib/web/htdocs/modules/themes/holiday/drawer.jpg b/lib/web/htdocs/modules/themes/holiday/drawer.jpg new file mode 100644 index 0000000000000000000000000000000000000000..794eb2b86d27c6d415bde9e07ff959b1be8a9432 --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/drawer.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87d98ee382854d207862bc62ca39356d4a8778a64fcf5d69d172b548b211a50c +size 561168 diff --git a/lib/web/htdocs/modules/themes/holiday/theme.css b/lib/web/htdocs/modules/themes/holiday/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..62e30ef278c3c7086d8d800fd61ea59cf1822576 --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/theme.css @@ -0,0 +1,478 @@ +:root { + --theme-primary-color: #00b32c; + --theme-text-color: rgba(255, 255, 255, 0.87); + --theme-text-color-opaque: #fff; + --theme-accent-text-color: #00b32c; + --theme-primary-color-lightened: #5ec157; + --theme-icon-focus-background: rgba(82, 181, 75, 0.2); + --theme-background: rgba(20, 20, 20, 0.6); + --button-background: #242424; + --card-background: #242424; + --header-background: #141414 url(bg.jpg) no-repeat center top; + --header-blur-background: rgba(20, 20, 20, 0.66); + --footer-background: #1d1d1d; + --footer-blur-background: rgba(29, 29, 31, 0.66); + --theme-body-secondary-text-color: rgba(255, 255, 255, 0.6); + --line-background: rgba(255, 255, 255, 0.08); + --line-size: 0.08em; + --scrollbar-thumb-background: rgba(255, 255, 255, 0.3); + --drawer-background: #2c2c2e; + --docked-drawer-background: #1c1c1e; + --logo-url: /modules/themes/logowhite.png; +} +.layout-tv:root { + --theme-background: #1a1a1a; + --header-blur-background: rgba(26, 26, 26, 0.66); + --drawer-background: #262626; + --docked-drawer-background: #262626; + --button-background: #2c2c2c; + --card-background: #2c2c2c; + --footer-background: #282828; + --footer-blur-background: rgba(40, 40, 40, 0.66); +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground.skinHeader-withfulldrawer { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer { + background: no-repeat center top; + -webkit-background-size: cover; + background-size: cover; +} +.backgroundContainer.withBackdrop { + background-color: rgba(0, 0, 0, 0.83); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(85, 85, 85, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #fff; + background: rgba(56, 56, 56, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + .toast-large { + color: rgba(255, 255, 255, 0.87); + } +} +.fab:focus, +.raised:focus { + background: #333; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(255, 255, 255, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #1c1c1c; + background: rgba(30, 30, 30, 0.9); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: rgba(54, 54, 54, 0.8); +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid var(--button-background); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: #303030; + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #d2b019; +} +.defaultCardBackground2 { + background-color: #338abb; +} +.defaultCardBackground3 { + background-color: #6b689d; +} +.defaultCardBackground4 { + background-color: #dd452b; +} +.defaultCardBackground5 { + background-color: #5ccea9; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logowhite.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/holiday/theme.js b/lib/web/htdocs/modules/themes/holiday/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d8184594600a0f2a3ffaa35ea7d3deeaa210991f --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/theme.js @@ -0,0 +1,3 @@ +$(document).ready(setTimeout(function(){ + $('.backgroundContainer').css({'background-image': 'linear-gradient( var(--theme-background), var(--theme-background) ), url(/background)'}); +}, 100)); diff --git a/lib/web/htdocs/modules/themes/holiday/theme.json b/lib/web/htdocs/modules/themes/holiday/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..c7347e9a9893879ab985595bea2286cc8c8f649c --- /dev/null +++ b/lib/web/htdocs/modules/themes/holiday/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#313235", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "light" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/light-blue/__init__.py b/lib/web/htdocs/modules/themes/light-blue/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/light-blue/theme.css b/lib/web/htdocs/modules/themes/light-blue/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..256526e981bee546f66946df42de5c31f5fa4fc9 --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-blue/theme.css @@ -0,0 +1,501 @@ +:root { + --theme-primary-color: #2196f3; + --theme-text-color: rgba(0, 0, 0, 0.87); + --theme-text-color-opaque: #000; + --theme-accent-text-color: #2196f3; + --theme-primary-color-lightened: #2da2ff; + --theme-icon-focus-background: rgba(33, 150, 243, 0.2); + --theme-background: #fff; + --button-background: #eeeef0; + --card-background: #f7f7f9; + --header-background: var(--theme-background); + --header-blur-background: rgba(255, 255, 255, 0.72); + --footer-background: #fff; + --footer-blur-background: var(--header-blur-background); + --theme-body-secondary-text-color: rgba(0, 0, 0, 0.6); + --line-background: rgba(0, 0, 0, 0.08); + --line-size: 0.12em; + --scrollbar-thumb-background: rgba(0, 0, 0, 0.3); + --drawer-background: #f3f2f8; + --docked-drawer-background: #f3f2f8; + --logo-url: /modules/themes/logodark.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground { + background: var(--theme-primary-color); + -webkit-box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + color: #fff; +} +.appfooter { + border-top: var(--line-size) solid var(--line-background); + bottom: -0.24em !important; +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +.formDialogHeader:not(.formDialogHeader-clear) { + border-bottom: var(--line-size) solid var(--line-background); +} +.formDialogFooter:not(.formDialogFooter-clear) { + border-top: var(--line-size) solid var(--line-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.navDrawerLogo { + background-image: url(../logodark.png); +} +.backgroundContainer, +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer.withBackdrop { + background-color: rgba(255, 255, 255, 0.88); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.skinHeader-withBackground .paper-icon-button-light-tv:focus, +.skinHeader-withBackground .paper-icon-button-light:active { + color: #fff; + background-color: rgba(255, 255, 255, 0.2); +} +@media (hover: hover) and (pointer: fine) { + .skinHeader-withBackground .paper-icon-button-light:focus { + color: #fff; + background-color: rgba(255, 255, 255, 0.2); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter, .headerHelpButton) { + background: rgba(140, 140, 140, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .emby-select-withcolor.detailTrackSelect { + border-color: transparent; + } + .dialog-blur, + .toast { + color: #000; + background: rgba(240, 240, 240, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } +} +.fab:focus, +.raised:focus { + background: #ccc; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.paperList { + border: var(--line-size) solid var(--line-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #f8f8f8; +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: #ddd; +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: rgba(0, 0, 0, 0.1); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: #fff; + color: rgba(255, 255, 255, 0.5); +} +.emby-tab-button-active { + color: #fff; + color: #fff; +} +.emby-tab-button-active.emby-button-tv { + color: #fff; + color: #fff; +} +.emby-tab-button.emby-button-tv:focus { + color: #fff; + color: #fff; + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.dockedtabs-tab-button { + color: #000; + color: rgba(000, 000, 000, 0.5); +} +.dockedtabs-tab-button.emby-tab-button-active { + color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(0, 0, 0, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + border: var(--line-size) solid var(--line-background); +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); + -webkit-box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); + box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #009688; + color: #fff !important; +} +.defaultCardBackground2 { + background-color: #d32f2f; + color: #fff !important; +} +.defaultCardBackground3 { + background-color: #0288d1; + color: #fff !important; +} +.defaultCardBackground4 { + background-color: #388e3c; + color: #fff !important; +} +.defaultCardBackground5 { + background-color: #f57f17; + color: #fff !important; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logodark.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/light-blue/theme.js b/lib/web/htdocs/modules/themes/light-blue/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-blue/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/light-blue/theme.json b/lib/web/htdocs/modules/themes/light-blue/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..60f0a301dd10f8e8c03fe395153e6e54d056199f --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-blue/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#2196F3", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "dark" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/light-pink/__init__.py b/lib/web/htdocs/modules/themes/light-pink/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/light-pink/theme.css b/lib/web/htdocs/modules/themes/light-pink/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..35c568262f4ea9a2d0ced8c69e45d9a4c6b6a9a2 --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-pink/theme.css @@ -0,0 +1,501 @@ +:root { + --theme-primary-color: #e91e63; + --theme-text-color: rgba(0, 0, 0, 0.87); + --theme-text-color-opaque: #000; + --theme-accent-text-color: #e91e63; + --theme-primary-color-lightened: #f52a6f; + --theme-icon-focus-background: rgba(233, 30, 99, 0.2); + --theme-background: #fff; + --button-background: #eeeef0; + --card-background: #f7f7f9; + --header-background: var(--theme-background); + --header-blur-background: rgba(255, 255, 255, 0.72); + --footer-background: #fff; + --footer-blur-background: var(--header-blur-background); + --theme-body-secondary-text-color: rgba(0, 0, 0, 0.6); + --line-background: rgba(0, 0, 0, 0.08); + --line-size: 0.12em; + --scrollbar-thumb-background: rgba(0, 0, 0, 0.3); + --drawer-background: #f3f2f8; + --docked-drawer-background: #f3f2f8; + --logo-url: /modules/themes/logodark.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground { + background: var(--theme-primary-color); + -webkit-box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + color: #fff; +} +.appfooter { + border-top: var(--line-size) solid var(--line-background); + bottom: -0.24em !important; +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +.formDialogHeader:not(.formDialogHeader-clear) { + border-bottom: var(--line-size) solid var(--line-background); +} +.formDialogFooter:not(.formDialogFooter-clear) { + border-top: var(--line-size) solid var(--line-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.navDrawerLogo { + background-image: url(../logodark.png); +} +.backgroundContainer, +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer.withBackdrop { + background-color: rgba(255, 255, 255, 0.88); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.skinHeader-withBackground .paper-icon-button-light-tv:focus, +.skinHeader-withBackground .paper-icon-button-light:active { + color: #fff; + background-color: rgba(255, 255, 255, 0.2); +} +@media (hover: hover) and (pointer: fine) { + .skinHeader-withBackground .paper-icon-button-light:focus { + color: #fff; + background-color: rgba(255, 255, 255, 0.2); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter, .headerHelpButton) { + background: rgba(140, 140, 140, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .emby-select-withcolor.detailTrackSelect { + border-color: transparent; + } + .dialog-blur, + .toast { + color: #000; + background: rgba(240, 240, 240, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } +} +.fab:focus, +.raised:focus { + background: #ccc; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.paperList { + border: var(--line-size) solid var(--line-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #f8f8f8; +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: #ddd; +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: rgba(0, 0, 0, 0.1); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: #fff; + color: rgba(255, 255, 255, 0.5); +} +.emby-tab-button-active { + color: #fff; + color: #fff; +} +.emby-tab-button-active.emby-button-tv { + color: #fff; + color: #fff; +} +.emby-tab-button.emby-button-tv:focus { + color: #fff; + color: #fff; + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.dockedtabs-tab-button { + color: #000; + color: rgba(000, 000, 000, 0.5); +} +.dockedtabs-tab-button.emby-tab-button-active { + color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(0, 0, 0, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + border: var(--line-size) solid var(--line-background); +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); + -webkit-box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); + box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #009688; + color: #fff !important; +} +.defaultCardBackground2 { + background-color: #d32f2f; + color: #fff !important; +} +.defaultCardBackground3 { + background-color: #0288d1; + color: #fff !important; +} +.defaultCardBackground4 { + background-color: #388e3c; + color: #fff !important; +} +.defaultCardBackground5 { + background-color: #f57f17; + color: #fff !important; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logodark.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/light-pink/theme.js b/lib/web/htdocs/modules/themes/light-pink/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-pink/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/light-pink/theme.json b/lib/web/htdocs/modules/themes/light-pink/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..cdb8f8774792800e7564c0a1da2c9ca8c099d31f --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-pink/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#E91E63", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "dark" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/light-purple/__init__.py b/lib/web/htdocs/modules/themes/light-purple/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/light-purple/theme.css b/lib/web/htdocs/modules/themes/light-purple/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..350f6da1053b77c3807af584b53d0b910b01e8dd --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-purple/theme.css @@ -0,0 +1,501 @@ +:root { + --theme-primary-color: #673ab7; + --theme-text-color: rgba(0, 0, 0, 0.87); + --theme-text-color-opaque: #000; + --theme-accent-text-color: #673ab7; + --theme-primary-color-lightened: #7346c3; + --theme-icon-focus-background: rgba(103, 58, 183, 0.2); + --theme-background: #fff; + --button-background: #eeeef0; + --card-background: #f7f7f9; + --header-background: var(--theme-background); + --header-blur-background: rgba(255, 255, 255, 0.72); + --footer-background: #fff; + --footer-blur-background: var(--header-blur-background); + --theme-body-secondary-text-color: rgba(0, 0, 0, 0.6); + --line-background: rgba(0, 0, 0, 0.08); + --line-size: 0.12em; + --scrollbar-thumb-background: rgba(0, 0, 0, 0.3); + --drawer-background: #f3f2f8; + --docked-drawer-background: #f3f2f8; + --logo-url: /modules/themes/logodark.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground { + background: var(--theme-primary-color); + -webkit-box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + color: #fff; +} +.appfooter { + border-top: var(--line-size) solid var(--line-background); + bottom: -0.24em !important; +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +.formDialogHeader:not(.formDialogHeader-clear) { + border-bottom: var(--line-size) solid var(--line-background); +} +.formDialogFooter:not(.formDialogFooter-clear) { + border-top: var(--line-size) solid var(--line-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.navDrawerLogo { + background-image: url(../logodark.png); +} +.backgroundContainer, +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer.withBackdrop { + background-color: rgba(255, 255, 255, 0.88); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.skinHeader-withBackground .paper-icon-button-light-tv:focus, +.skinHeader-withBackground .paper-icon-button-light:active { + color: #fff; + background-color: rgba(255, 255, 255, 0.2); +} +@media (hover: hover) and (pointer: fine) { + .skinHeader-withBackground .paper-icon-button-light:focus { + color: #fff; + background-color: rgba(255, 255, 255, 0.2); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter, .headerHelpButton) { + background: rgba(140, 140, 140, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .emby-select-withcolor.detailTrackSelect { + border-color: transparent; + } + .dialog-blur, + .toast { + color: #000; + background: rgba(240, 240, 240, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } +} +.fab:focus, +.raised:focus { + background: #ccc; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.paperList { + border: var(--line-size) solid var(--line-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #f8f8f8; +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: #ddd; +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: rgba(0, 0, 0, 0.1); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: #fff; + color: rgba(255, 255, 255, 0.5); +} +.emby-tab-button-active { + color: #fff; + color: #fff; +} +.emby-tab-button-active.emby-button-tv { + color: #fff; + color: #fff; +} +.emby-tab-button.emby-button-tv:focus { + color: #fff; + color: #fff; + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.dockedtabs-tab-button { + color: #000; + color: rgba(000, 000, 000, 0.5); +} +.dockedtabs-tab-button.emby-tab-button-active { + color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(0, 0, 0, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + border: var(--line-size) solid var(--line-background); +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); + -webkit-box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); + box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #009688; + color: #fff !important; +} +.defaultCardBackground2 { + background-color: #d32f2f; + color: #fff !important; +} +.defaultCardBackground3 { + background-color: #0288d1; + color: #fff !important; +} +.defaultCardBackground4 { + background-color: #388e3c; + color: #fff !important; +} +.defaultCardBackground5 { + background-color: #f57f17; + color: #fff !important; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logodark.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/light-purple/theme.js b/lib/web/htdocs/modules/themes/light-purple/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-purple/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/light-purple/theme.json b/lib/web/htdocs/modules/themes/light-purple/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..c8798dfdb3fb2b3252bf4162c1737d2a4b0f0874 --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-purple/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#673AB7", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "dark" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/light-red/__init__.py b/lib/web/htdocs/modules/themes/light-red/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/light-red/theme.css b/lib/web/htdocs/modules/themes/light-red/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..a5c5dc0129e825b8b71c19e80b815f66a823c76f --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-red/theme.css @@ -0,0 +1,501 @@ +:root { + --theme-primary-color: #cc3333; + --theme-text-color: rgba(0, 0, 0, 0.87); + --theme-text-color-opaque: #000; + --theme-accent-text-color: #cc3333; + --theme-primary-color-lightened: #d83f3f; + --theme-icon-focus-background: rgba(204, 51, 51, 0.2); + --theme-background: #fff; + --button-background: #eeeef0; + --card-background: #f7f7f9; + --header-background: var(--theme-background); + --header-blur-background: rgba(255, 255, 255, 0.72); + --footer-background: #fff; + --footer-blur-background: var(--header-blur-background); + --theme-body-secondary-text-color: rgba(0, 0, 0, 0.6); + --line-background: rgba(0, 0, 0, 0.08); + --line-size: 0.12em; + --scrollbar-thumb-background: rgba(0, 0, 0, 0.3); + --drawer-background: #f3f2f8; + --docked-drawer-background: #f3f2f8; + --logo-url: /modules/themes/logodark.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground { + background: var(--theme-primary-color); + -webkit-box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + color: #fff; +} +.appfooter { + border-top: var(--line-size) solid var(--line-background); + bottom: -0.24em !important; +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +.formDialogHeader:not(.formDialogHeader-clear) { + border-bottom: var(--line-size) solid var(--line-background); +} +.formDialogFooter:not(.formDialogFooter-clear) { + border-top: var(--line-size) solid var(--line-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.navDrawerLogo { + background-image: url(../logodark.png); +} +.backgroundContainer, +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer.withBackdrop { + background-color: rgba(255, 255, 255, 0.88); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.skinHeader-withBackground .paper-icon-button-light-tv:focus, +.skinHeader-withBackground .paper-icon-button-light:active { + color: #fff; + background-color: rgba(255, 255, 255, 0.2); +} +@media (hover: hover) and (pointer: fine) { + .skinHeader-withBackground .paper-icon-button-light:focus { + color: #fff; + background-color: rgba(255, 255, 255, 0.2); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter, .headerHelpButton) { + background: rgba(140, 140, 140, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .emby-select-withcolor.detailTrackSelect { + border-color: transparent; + } + .dialog-blur, + .toast { + color: #000; + background: rgba(240, 240, 240, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } +} +.fab:focus, +.raised:focus { + background: #ccc; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.paperList { + border: var(--line-size) solid var(--line-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #f8f8f8; +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: #ddd; +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: rgba(0, 0, 0, 0.1); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: #fff; + color: rgba(255, 255, 255, 0.5); +} +.emby-tab-button-active { + color: #fff; + color: #fff; +} +.emby-tab-button-active.emby-button-tv { + color: #fff; + color: #fff; +} +.emby-tab-button.emby-button-tv:focus { + color: #fff; + color: #fff; + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.dockedtabs-tab-button { + color: #000; + color: rgba(000, 000, 000, 0.5); +} +.dockedtabs-tab-button.emby-tab-button-active { + color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(0, 0, 0, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + border: var(--line-size) solid var(--line-background); +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); + -webkit-box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); + box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #009688; + color: #fff !important; +} +.defaultCardBackground2 { + background-color: #d32f2f; + color: #fff !important; +} +.defaultCardBackground3 { + background-color: #0288d1; + color: #fff !important; +} +.defaultCardBackground4 { + background-color: #388e3c; + color: #fff !important; +} +.defaultCardBackground5 { + background-color: #f57f17; + color: #fff !important; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logodark.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/light-red/theme.js b/lib/web/htdocs/modules/themes/light-red/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-red/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/light-red/theme.json b/lib/web/htdocs/modules/themes/light-red/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..ffad49665196cc6002f0a239c96649dba6ea9bfb --- /dev/null +++ b/lib/web/htdocs/modules/themes/light-red/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#cc3333", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "dark" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/light/__init__.py b/lib/web/htdocs/modules/themes/light/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/light/theme.css b/lib/web/htdocs/modules/themes/light/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..53403965cd6952d6ec1b925eb98393a88a0bd2be --- /dev/null +++ b/lib/web/htdocs/modules/themes/light/theme.css @@ -0,0 +1,481 @@ +:root { + --theme-primary-color: #52b54b; + --theme-text-color: rgba(0, 0, 0, 0.87); + --theme-text-color-opaque: #000; + --theme-accent-text-color: green; + --theme-primary-color-lightened: #5ec157; + --theme-icon-focus-background: rgba(82, 181, 75, 0.2); + --theme-background: #fff; + --button-background: #eeeef0; + --card-background: #f7f7f9; + --header-background: var(--theme-background); + --header-blur-background: rgba(255, 255, 255, 0.72); + --footer-background: #fff; + --footer-blur-background: var(--header-blur-background); + --theme-body-secondary-text-color: rgba(0, 0, 0, 0.6); + --line-background: rgba(0, 0, 0, 0.08); + --line-size: 0.12em; + --scrollbar-thumb-background: rgba(0, 0, 0, 0.3); + --drawer-background: #f3f2f8; + --docked-drawer-background: #f3f2f8; + --logo-url: /modules/themes/logodark.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter { + border-top: var(--line-size) solid var(--line-background); + bottom: -0.24em !important; +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +.formDialogHeader:not(.formDialogHeader-clear) { + border-bottom: var(--line-size) solid var(--line-background); +} +.formDialogFooter:not(.formDialogFooter-clear) { + border-top: var(--line-size) solid var(--line-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logodark.png); +} +.backgroundContainer, +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer.withBackdrop { + background-color: rgba(255, 255, 255, 0.88); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(140, 140, 140, 0.3); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .emby-select-withcolor.detailTrackSelect { + border-color: transparent; + } + .dialog-blur, + .toast { + color: #000; + background: rgba(240, 240, 240, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } +} +.fab:focus, +.raised:focus { + background: #ccc; +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.paperList { + border: var(--line-size) solid var(--line-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.2); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #f8f8f8; +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: #ddd; +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: #fff; + border: var(--line-size) solid rgba(0, 0, 0, 0.158); +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: rgba(0, 0, 0, 0.1); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: var(--theme-background) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: var(--theme-background) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(0, 0, 0, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + border: var(--line-size) solid var(--line-background); +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + background-color: var(--card-background); + -webkit-box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); + box-shadow: 0 0.25em 0.875em rgba(0, 0, 0, 0.1); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: #009688; + color: #fff !important; +} +.defaultCardBackground2 { + background-color: #d32f2f; + color: #fff !important; +} +.defaultCardBackground3 { + background-color: #0288d1; + color: #fff !important; +} +.defaultCardBackground4 { + background-color: #388e3c; + color: #fff !important; +} +.defaultCardBackground5 { + background-color: #f57f17; + color: #fff !important; +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logodark.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/light/theme.js b/lib/web/htdocs/modules/themes/light/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/light/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/light/theme.json b/lib/web/htdocs/modules/themes/light/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..4a0dd6a8b5c968553f88d7845dc2f4886975e72e --- /dev/null +++ b/lib/web/htdocs/modules/themes/light/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#ffffff", + "androidStatusBarForegroundColor": "dark", + "androidNavigationBarForegroundColor": "dark" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/logodark.png b/lib/web/htdocs/modules/themes/logodark.png new file mode 100644 index 0000000000000000000000000000000000000000..35a7f87527d160f8298e18d42edaa24088532a34 Binary files /dev/null and b/lib/web/htdocs/modules/themes/logodark.png differ diff --git a/lib/web/htdocs/modules/themes/logodark128.png b/lib/web/htdocs/modules/themes/logodark128.png new file mode 100644 index 0000000000000000000000000000000000000000..5f307fb4f7b9264c016c38c2aa07ed642bd1a9bf Binary files /dev/null and b/lib/web/htdocs/modules/themes/logodark128.png differ diff --git a/lib/web/htdocs/modules/themes/logodark200.png b/lib/web/htdocs/modules/themes/logodark200.png new file mode 100644 index 0000000000000000000000000000000000000000..ce33ab91020e0e5ca8676371a03a10922c889154 Binary files /dev/null and b/lib/web/htdocs/modules/themes/logodark200.png differ diff --git a/lib/web/htdocs/modules/themes/logodark377.png b/lib/web/htdocs/modules/themes/logodark377.png new file mode 100644 index 0000000000000000000000000000000000000000..35a7f87527d160f8298e18d42edaa24088532a34 Binary files /dev/null and b/lib/web/htdocs/modules/themes/logodark377.png differ diff --git a/lib/web/htdocs/modules/themes/logowhite.png b/lib/web/htdocs/modules/themes/logowhite.png new file mode 100644 index 0000000000000000000000000000000000000000..189cd9bf0c976faa5570f4aa6e97f4ce782b6c4d Binary files /dev/null and b/lib/web/htdocs/modules/themes/logowhite.png differ diff --git a/lib/web/htdocs/modules/themes/logowhite128.png b/lib/web/htdocs/modules/themes/logowhite128.png new file mode 100644 index 0000000000000000000000000000000000000000..8b84db4d89b7a07a65eb760624e14d3a5e28fdb2 Binary files /dev/null and b/lib/web/htdocs/modules/themes/logowhite128.png differ diff --git a/lib/web/htdocs/modules/themes/logowhite200.png b/lib/web/htdocs/modules/themes/logowhite200.png new file mode 100644 index 0000000000000000000000000000000000000000..d999742b4611e478638ff9853d3ab022425120ba Binary files /dev/null and b/lib/web/htdocs/modules/themes/logowhite200.png differ diff --git a/lib/web/htdocs/modules/themes/logowhite377.png b/lib/web/htdocs/modules/themes/logowhite377.png new file mode 100644 index 0000000000000000000000000000000000000000..c91923aea454ff9921ff6b9f164ff1824849af49 Binary files /dev/null and b/lib/web/htdocs/modules/themes/logowhite377.png differ diff --git a/lib/web/htdocs/modules/themes/spring/__init__.py b/lib/web/htdocs/modules/themes/spring/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/spring/bg.jpg b/lib/web/htdocs/modules/themes/spring/bg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9a2809dc1a5c6044f34ea9a334aaee527107829c --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/bg.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09b5f8c32c7538b49008d7e9678b3dc2a7a90aa0b8089fad8b3639a54c982571 +size 786033 diff --git a/lib/web/htdocs/modules/themes/spring/bg1.jpg b/lib/web/htdocs/modules/themes/spring/bg1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6370522b1a68f4120eb64f51771f1af9ac2bc596 --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/bg1.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f964cefd4a3c0032ba54c17a947e6c411868c92ed0a391ee58257bcad277ef1a +size 412488 diff --git a/lib/web/htdocs/modules/themes/spring/bg2.jpg b/lib/web/htdocs/modules/themes/spring/bg2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..22519f8da151ff1fd75c8f036895b9452f29b031 --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/bg2.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c081e965db0c85ecf423f5f85ddf08914b77100dc0531edf38a329169f42c36 +size 427421 diff --git a/lib/web/htdocs/modules/themes/spring/bg3.jpg b/lib/web/htdocs/modules/themes/spring/bg3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..756260e0bb16b67f1de250cb990517ad0c5a8f52 --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/bg3.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9568c47b26972841b5bbec8885780b9646f28382d2a1ba347d45e204e4b6c733 +size 908881 diff --git a/lib/web/htdocs/modules/themes/spring/bg4.jpg b/lib/web/htdocs/modules/themes/spring/bg4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9a2809dc1a5c6044f34ea9a334aaee527107829c --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/bg4.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09b5f8c32c7538b49008d7e9678b3dc2a7a90aa0b8089fad8b3639a54c982571 +size 786033 diff --git a/lib/web/htdocs/modules/themes/spring/bg5.jpg b/lib/web/htdocs/modules/themes/spring/bg5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0644ec340711a97876b9e06bce2f0bc3b1ee1bb9 --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/bg5.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc4b9e3a4bc1c0bd015c03d19e0c9f13900925fa4ed91c63b94a9cb59008141b +size 224874 diff --git a/lib/web/htdocs/modules/themes/spring/theme.css b/lib/web/htdocs/modules/themes/spring/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..2336891a7245ec8a4184e05d1545268ec1476d5e --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/theme.css @@ -0,0 +1,471 @@ +:root { + --theme-primary-color: #52b54b; + --theme-text-color: rgba(255, 255, 255, 0.87); + --theme-text-color-opaque: rgba(255, 255, 255, 1.0); + --theme-accent-text-color: rgba(255, 255, 122, 1.0); + --theme-primary-color-lightened: rgba(94, 193, 87, 1.0); + --theme-icon-focus-background: rgba(82, 181, 75, 0.4); + --theme-background: rgba(23, 21, 57, 0.6); + --button-background: rgba(40, 40, 40, 0.7); + --card-background: rgba(10, 10, 10, 0.5); + --header-background: rgba(3, 51, 97, 0.5) url(bg.jpg) no-repeat center top; + --header-blur-background: rgba(3, 51, 97, 0.7); + --footer-background: #033664; + --footer-blur-background: var(--footer-background); + --theme-body-secondary-text-color: rgba(255, 255, 255, 0.6); + --line-background: rgba(255, 255, 255, 0.5); + --line-size: 0.08em; + --scrollbar-thumb-background: rgba(133, 115, 113, 1.0); + --drawer-background: rgba(21, 20, 50, 0.9); + --docked-drawer-background: rgba(0, 0, 0, 0.4); + --logo-url: /modules/themes/logowhite.png; + --theme-button-hover-color: rgba(28, 141, 173, 0.718); +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground.skinHeader-withfulldrawer { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer { + background: no-repeat center top; + -webkit-background-size: cover; + background-size: cover; +} +.backgroundContainer.withBackdrop { + opacity: 0.88; +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + opacity: 1; + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(0, 0, 0, 0.4); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #fff; + background: rgba(56, 56, 56, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + .toast-large { + color: rgba(255, 255, 255, 0.87); + } +} +.fab:focus, +.raised:focus { + background: rgba(0, 0, 0, 0.3); +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.3); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #1c1c1c; + background: rgba(30, 30, 30, 0.9); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: rgba(54, 54, 54, 0.8); +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: var(--theme-button-hover-color); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: rgba(13, 42, 86, 0.8) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: rgba(13, 42, 86, 0.8) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + -webkit-box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: var(--card-background); +} +.defaultCardBackground2 { + background-color: var(--card-background); +} +.defaultCardBackground3 { + background-color: var(--card-background); +} +.defaultCardBackground4 { + background-color: var(--card-background); +} +.defaultCardBackground5 { + background-color: var(--card-background); +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logowhite.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/spring/theme.js b/lib/web/htdocs/modules/themes/spring/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d8184594600a0f2a3ffaa35ea7d3deeaa210991f --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/theme.js @@ -0,0 +1,3 @@ +$(document).ready(setTimeout(function(){ + $('.backgroundContainer').css({'background-image': 'linear-gradient( var(--theme-background), var(--theme-background) ), url(/background)'}); +}, 100)); diff --git a/lib/web/htdocs/modules/themes/spring/theme.json b/lib/web/htdocs/modules/themes/spring/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..a208fa72d3c0a837149e7916bf4cf3b4738190d3 --- /dev/null +++ b/lib/web/htdocs/modules/themes/spring/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#011432", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "light" +} \ No newline at end of file diff --git a/lib/web/htdocs/modules/themes/wmc/__init__.py b/lib/web/htdocs/modules/themes/wmc/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/web/htdocs/modules/themes/wmc/theme.css b/lib/web/htdocs/modules/themes/wmc/theme.css new file mode 100644 index 0000000000000000000000000000000000000000..216a4fdde69e238d566a1a84cf90b56b17d49c54 --- /dev/null +++ b/lib/web/htdocs/modules/themes/wmc/theme.css @@ -0,0 +1,474 @@ +:root { + --theme-primary-color: #52b54b; + --theme-text-color: rgba(255, 255, 255, 0.87); + --theme-text-color-opaque: #fff; + --theme-accent-text-color: #52b54b; + --theme-primary-color-lightened: #5ec157; + --theme-icon-focus-background: rgba(82, 181, 75, 0.2); + --theme-background: #033361; + --button-background: rgba(0, 0, 0, 0.5); + --card-background: rgba(0, 0, 0, 0.5); + --header-background: var(--theme-background); + --header-blur-background: linear-gradient(to right, rgba(6, 38, 99, 0.88), rgba(8, 52, 113, 0.88), rgba(9, 69, 139, 0.88), rgba(18, 93, 160, 0.88), rgba(10, 57, 129, 0.88)); + --footer-background: #033664; + --footer-blur-background: linear-gradient(to right, rgba(6, 38, 99, 0.88), rgba(8, 52, 113, 0.88), rgba(9, 69, 139, 0.88), rgba(18, 93, 160, 0.88), rgba(10, 57, 129, 0.88)); + --theme-body-secondary-text-color: rgba(255, 255, 255, 0.6); + --line-background: rgba(255, 255, 255, 0.08); + --line-size: 0.13em; + --scrollbar-thumb-background: rgba(255, 255, 255, 0.7); + --drawer-background: #011432; + --docked-drawer-background: rgba(0, 0, 0, 0.2); + --logo-url: /modules/themes/logowhite.png; +} +html { + color: var(--theme-text-color); + scrollbar-color: var(--scrollbar-thumb-background) transparent; +} +.emby-collapsible-button { + border-color: var(--line-background) !important; +} +.skinHeader-withBackground.skinHeader-withfulldrawer { + border-bottom: 0.08em solid var(--line-background); +} +.skinHeader-withBackground { + background: var(--header-background); +} +.appfooter, +.formDialogFooter:not(.formDialogFooter-clear), +.formDialogHeader:not(.formDialogHeader-clear) { + background: var(--footer-background); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .skinHeader-withBackground { + background: var(--header-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .appfooter-withbackdropfilter { + background: var(--footer-blur-background); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } +} +.skinHeader.semiTransparent { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background-color: rgba(0, 0, 0, 0.3); + background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.6)), to(rgba(0, 0, 0, 0))); + background: -webkit-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: -o-linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); + -webkit-box-shadow: none !important; + box-shadow: none !important; + border-bottom: 0; + color: rgba(255, 255, 255, 0.87); +} +.pageTitleWithDefaultLogo { + background-image: url(../logowhite.png); +} +.dialog, +html { + background-color: var(--theme-background); +} +.backgroundContainer { + background: -webkit-gradient(linear, left top, right top, from(#062663), color-stop(#083471), color-stop(#09458b), color-stop(#125da0), to(#0a3981)); + background: -webkit-linear-gradient(left, #062663, #083471, #09458b, #125da0, #0a3981); + background: -o-linear-gradient(left, #062663, #083471, #09458b, #125da0, #0a3981); + background: linear-gradient(to right, #062663, #083471, #09458b, #125da0, #0a3981); +} +.backgroundContainer.withBackdrop { + background: -webkit-gradient(linear, left top, right top, from(rgba(6, 38, 99, 0.88)), color-stop(rgba(8, 52, 113, 0.88)), color-stop(rgba(9, 69, 139, 0.88)), color-stop(rgba(18, 93, 160, 0.88)), to(rgba(10, 57, 129, 0.88))); + background: -webkit-linear-gradient(left, rgba(6, 38, 99, 0.88), rgba(8, 52, 113, 0.88), rgba(9, 69, 139, 0.88), rgba(18, 93, 160, 0.88), rgba(10, 57, 129, 0.88)); + background: -o-linear-gradient(left, rgba(6, 38, 99, 0.88), rgba(8, 52, 113, 0.88), rgba(9, 69, 139, 0.88), rgba(18, 93, 160, 0.88), rgba(10, 57, 129, 0.88)); + background: linear-gradient(to right, rgba(6, 38, 99, 0.88), rgba(8, 52, 113, 0.88), rgba(9, 69, 139, 0.88), rgba(18, 93, 160, 0.88), rgba(10, 57, 129, 0.88)); +} +@media not all and (min-width: 50em) { + .itemBackgroundContainer.withBackdrop { + background-color: var(--theme-background); + } +} +.paper-icon-button-light-tv:focus, +.paper-icon-button-light:active { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); +} +@media (hover: hover) and (pointer: fine) { + .paper-icon-button-light:focus { + color: var(--theme-primary-color); + background-color: var(--theme-icon-focus-background); + } +} +.detailButton-icon, +.fab, +.raised { + background: var(--button-background); + color: var(--theme-text-color); +} +.detailButton-icon { + border-color: rgba(255, 255, 255, 0.3); +} +.emby-select-withcolor { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.toast { + background: var(--button-background); + color: var(--theme-text-color); +} +@supports (backdrop-filter: blur(1em)) or (-webkit-backdrop-filter: blur(1em)) { + .detailButton-icon, + .emby-select-withcolor.detailTrackSelect, + .fab, + .raised:not(.nobackdropfilter) { + background: rgba(0, 0, 0, 0.4); + -webkit-backdrop-filter: saturate(1.8) blur(1.5em); + backdrop-filter: saturate(1.8) blur(1.5em); + } + .dialog-blur, + .toast { + color: #fff; + background: rgba(56, 56, 56, 0.76); + -webkit-backdrop-filter: blur(2.5em) saturate(1.8); + backdrop-filter: blur(2.5em) saturate(1.8); + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + .toast-large { + color: rgba(255, 255, 255, 0.87); + } +} +.fab:focus, +.raised:focus { + background: rgba(0, 0, 0, 0.3); +} +.button-submit:not(.emby-button-tv) { + background: var(--theme-primary-color); + color: #fff; +} +.button-submit:not(.emby-button-tv):focus { + background: var(--theme-primary-color-lightened); + color: #fff; +} +.emby-select-withcolor > option { + color: inherit; + background: var(--button-background); +} +.emby-select-withcolor:focus { + border-color: var(--theme-primary-color) !important; +} +.emby-select-tv-withcolor:focus { + background-color: var(--theme-primary-color) !important; + color: #fff !important; +} +.checkboxLabel { + color: inherit; +} +.emby-checkbox-focusring:focus:before { + background-color: var(--theme-icon-focus-background); +} +.inputLabelFocused, +.selectLabelFocused, +.textareaLabelFocused { + color: var(--theme-accent-text-color); +} +.button-link { + color: var(--theme-accent-text-color); +} +.button-flat-accent { + color: var(--theme-accent-text-color); +} +.paperList, +.visualCardBox { + background-color: var(--card-background); +} +.collapseContent { + border: var(--line-size) solid var(--line-background); +} +.cardText-secondary, +.fieldDescription, +.listItemBodyText-secondary, +.secondaryText { + color: var(--theme-body-secondary-text-color); +} +.cardText-first { + color: var(--theme-text-color-opaque); +} +.actionsheetDivider { + background: var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .actionSheetMenuItem:hover { + background-color: rgba(0, 0, 0, 0.3); + } +} +.selectionCommandsPanel { + background: var(--theme-primary-color); + color: #fff; +} +.upNextDialog-countdownText { + color: var(--theme-primary-color); +} +.alphaPickerButton { + color: var(--theme-body-secondary-text-color); + background-color: transparent; +} +.alphaPickerButton-selected { + color: var(--theme-text-color-opaque); +} +.alphaPickerButton-tv:focus { + background-color: var(--theme-primary-color); + color: #fff !important; +} +.detailTableBodyRow-shaded:nth-child(even) { + background: #1c1c1c; + background: rgba(30, 30, 30, 0.9); +} +.listItem-border { + border-color: var(--line-background) !important; +} +.listItem-focusscale:focus { + background: rgba(0, 0, 0, 0.3); +} +.progressring-spiner { + border-color: var(--theme-primary-color); +} +.mediaInfoText { + background: var(--button-background); +} +.starIcon { + color: #cb272a; +} +.mediaInfoTimerIcon { + color: #cb272a; +} +.emby-input, +.emby-textarea { + color: inherit; + background: var(--button-background); + border: var(--line-size) solid transparent; +} +.emby-input:focus, +.emby-textarea:focus { + border-color: var(--theme-primary-color); +} +.emby-checkbox:checked + span:before { + border-color: currentColor; +} +.emby-checkbox:checked + span:before { + border-color: var(--theme-primary-color); + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground { + background-color: var(--theme-primary-color); +} +.itemProgressBarForeground-recording { + background-color: #cb272a; +} +.countIndicator { + background: var(--theme-primary-color); +} +.playedIndicator { + background: var(--theme-primary-color); +} +.mainDrawer { + background: var(--drawer-background); +} +.drawer-docked { + background: var(--docked-drawer-background); + border-right: var(--line-size) solid var(--line-background); +} +@media (hover: hover) and (pointer: fine) { + .navMenuOption:hover { + background: rgba(0, 0, 0, 0.4); + } +} +.navMenuOption-selected { + background-color: var(--theme-icon-focus-background) !important; + color: var(--theme-accent-text-color); +} +.emby-button-focusscale:focus, +.emby-button-focusscale:focus .detailButton-icon { + background: var(--theme-primary-color); + color: #fff; +} +.emby-tab-button { + color: var(--theme-body-secondary-text-color); +} +.emby-tab-button-active { + color: var(--theme-accent-text-color); +} +.emby-tab-button-active.emby-button-tv { + color: var(--theme-text-color-opaque); +} +.emby-tab-button.emby-button-tv:focus { + color: var(--theme-accent-text-color); + background: 0 0; +} +.emby-button { + outline-color: var(--theme-primary-color); +} +.channelCell, +.guide-headerTimeslots, +.timeslotHeaders { + background: var(--theme-background); +} +@media (pointer: coarse) { + .channelCell-mobilefocus { + background: var(--theme-background) !important; + } +} +.channelCell-mobilefocus:not(:focus-visible) { + background: rgba(13, 42, 86, 0.8) !important; +} +.channelCell-mobilefocus:not(:-moz-focusring) { + background: rgba(13, 42, 86, 0.8) !important; +} +.channelCell, +.epgRow, +.programCell { + border-color: rgba(255, 255, 255, 0.05); +} +.guide-currentTimeIndicatorDot { + border-right-color: var(--theme-icon-focus-background); +} +.guide-currentTimeIndicatorDot:after { + border-top-color: var(--theme-primary-color); +} +.firstChannelCell { + border-color: transparent; +} +.programCell-sports { + background: #3949ab !important; +} +.programCell-movie { + background: #5e35b1 !important; +} +.programCell-kids { + background: #039be5 !important; +} +.programCell-news { + background: #43a047 !important; +} +.channelCell:focus, +.programCell:focus { + background-color: var(--theme-primary-color); + color: #fff; +} +.guide-programTextIcon { + color: #1e1e1e; + background: #555; +} +.infoBanner { + background: var(--card-background); + padding: 1em; + -webkit-border-radius: 0.3em; + border-radius: 0.3em; +} +.ratingbutton-icon-withrating { + color: #c33 !important; +} +.downloadbutton-icon-on { + color: #4285f4; +} +.downloadbutton-icon-complete { + color: #4285f4; +} +.playstatebutton-icon-played { + color: #c33 !important; +} +.repeatButton-active { + color: #4285f4; +} +.card:focus .card-focuscontent { + border-color: var(--theme-primary-color); +} +.cardContent-button { + background-color: transparent; +} +.cardContent-shadow { + -webkit-box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37); + background-color: var(--card-background); +} +.defaultCardBackground0 { + background-color: var(--card-background); +} +.defaultCardBackground1 { + background-color: var(--card-background); +} +.defaultCardBackground2 { + background-color: var(--card-background); +} +.defaultCardBackground3 { + background-color: var(--card-background); +} +.defaultCardBackground4 { + background-color: var(--card-background); +} +.defaultCardBackground5 { + background-color: var(--card-background); +} +.cardOverlayButtonIcon { + background-color: var(--theme-primary-color); +} +::-webkit-scrollbar-track-piece { + background-color: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-background); +} +.emby-slider-background { + background: var(--scrollbar-thumb-background); +} +.emby-slider { + color: var(--theme-primary-color); +} +.emby-slider::-moz-range-track { + background: #444; +} +.emby-slider::-moz-range-progress { + background: var(--theme-primary-color); +} +.emby-slider::-webkit-slider-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-moz-range-thumb { + background: var(--theme-primary-color); +} +.emby-slider::-ms-thumb { + background: var(--theme-primary-color); +} +.emby-slider-background-lower { + background-color: var(--theme-primary-color); +} +.scrollbuttoncontainer { + color: #fff; + background: rgba(20, 20, 20, 0.5); +} +.recordingIcon-active { + color: #c33 !important; +} +.drawerLogo { + background-image: url(../logowhite.png); + border-bottom-color: var(--line-background); +} +.searchTabsContainer { + border-bottom: var(--line-size) solid var(--line-background); +} +.emby-search-tab-button.emby-tab-button-active { + background: var(--theme-accent-text-color) !important; +} +.textActionButton.dragging { + background: var(--button-background) !important; +} +.dragging-over.full-drop-target { + background: var(--theme-primary-color) !important; + color: #fff !important; +} +.dragging-over-top:before { + background: var(--theme-accent-text-color); +} +.dragging-over-bottom:after { + background: var(--theme-accent-text-color); +} diff --git a/lib/web/htdocs/modules/themes/wmc/theme.js b/lib/web/htdocs/modules/themes/wmc/theme.js new file mode 100644 index 0000000000000000000000000000000000000000..d3f5a12faa99758192ecc4ed3fc22c9249232e86 --- /dev/null +++ b/lib/web/htdocs/modules/themes/wmc/theme.js @@ -0,0 +1 @@ + diff --git a/lib/web/htdocs/modules/themes/wmc/theme.json b/lib/web/htdocs/modules/themes/wmc/theme.json new file mode 100644 index 0000000000000000000000000000000000000000..e2edef44b7973ea12cdae1ee72cda3c13dce058d --- /dev/null +++ b/lib/web/htdocs/modules/themes/wmc/theme.json @@ -0,0 +1,5 @@ +{ + "themeColor": "#0C2450", + "androidStatusBarForegroundColor": "light", + "androidNavigationBarForegroundColor": "light" +} \ No newline at end of file diff --git a/lib/web/pages/README.txt b/lib/web/pages/README.txt new file mode 100644 index 0000000000000000000000000000000000000000..865006178ebbc0624f177f78918d3076e54ad434 --- /dev/null +++ b/lib/web/pages/README.txt @@ -0,0 +1,3 @@ +urls associated with /pages/... are mostly added dynamically using decorators +and are found throughout the installs. As an example, see +./lib/config/configform_html.py diff --git a/lib/web/pages/__init__.py b/lib/web/pages/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..de20e8c2dd95ab9a56725eb40715b71e3fcb80ce --- /dev/null +++ b/lib/web/pages/__init__.py @@ -0,0 +1,5 @@ +import lib.web.pages.background +import lib.web.pages.index_js +import lib.web.pages.web_urls +import lib.web.pages.dashstatus_json +import lib.web.pages.manifest diff --git a/lib/web/pages/background.py b/lib/web/pages/background.py new file mode 100644 index 0000000000000000000000000000000000000000..016c92bca322189282bda10724bef5e0713ac8c3 --- /dev/null +++ b/lib/web/pages/background.py @@ -0,0 +1,80 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import importlib +import importlib.resources +import mimetypes +import random +import pathlib + +from lib.web.pages.templates import web_templates +from lib.common.decorators import getrequest + + +@getrequest.route('/background') +def background(_webserver): + send_random_image(_webserver) + + +def send_random_image(_webserver): + image = None + if not _webserver.config['display']['backgrounds']: + background_dir = _webserver.config['paths']['themes_pkg'] + '.' + \ + _webserver.config['display']['theme'] + image_list = list(importlib.resources.contents(background_dir)) + image_found = False + count = 10 + while not image_found and count > 0: + image = random.choice(image_list) + mime_lookup = mimetypes.guess_type(image) + if mime_lookup[0] is not None and \ + mime_lookup[0].startswith('image'): + image_found = True + count -= 1 + if image_found: + _webserver.do_file_response(200, background_dir, image) + else: + _webserver.logger.warning('No Background Image found: ' + background_dir) + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - Background Image Not Found')) + else: + lbackground = _webserver.config['display']['backgrounds'] + try: + image_found = False + count = 10 + full_image_path = None + while not image_found and count > 0: + image = random.choice(list(pathlib.Path(lbackground).rglob('*.*'))) + full_image_path = pathlib.Path(lbackground).joinpath(image) + mime_lookup = mimetypes.guess_type(str(full_image_path)) + if mime_lookup[0] is not None and mime_lookup[0].startswith('image'): + image_found = True + count -= 1 + if image_found: + _webserver.do_file_response(200, None, full_image_path) + _webserver.logger.debug('Background Image: {}'.format(str(image).replace(lbackground, '.'))) + else: + _webserver.logger.warning('Image not found at {}'.format(lbackground)) + _webserver.do_mime_response(404, 'text/html', + web_templates['htmlError'].format('404 - Background Image Not Found')) + + except (FileNotFoundError, IndexError): + _webserver.logger.warning('Background Theme Folder not found: ' + lbackground) + _webserver.do_mime_response(404, 'text/html', web_templates['htmlError'] + .format('404 - Background Folder Not Found')) diff --git a/lib/web/pages/dashstatus_json.py b/lib/web/pages/dashstatus_json.py new file mode 100644 index 0000000000000000000000000000000000000000..6c4269be39ba3c62c70efa756b1ceba73717313f --- /dev/null +++ b/lib/web/pages/dashstatus_json.py @@ -0,0 +1,76 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import datetime +import json +import logging +import urllib +import urllib.request + +from lib.common.decorators import getrequest +from lib.common.decorators import handle_url_except +from lib.db.db_scheduler import DBScheduler + + +@getrequest.route('/api/dashstatus.json') +def pages_dashstatus_json(_webserver): + dashstatus_js = DashStatusJS(_webserver.config) + expire_time = datetime.datetime.utcnow() + expire_str = expire_time.strftime("%a, %d %b %Y %H:%M:%S GMT") + _webserver.do_dict_response({ + 'code': 200, 'headers': {'Content-type': 'application/json', + 'Expires': expire_str + }, + 'text': dashstatus_js.get() + }) + return True + + +class DashStatusJS: + + def __init__(self, _config): + self.logger = logging.getLogger(__name__) + self.config = _config + + def get(self): + js = ''.join([ + '{ "tunerstatus": ', str(self.get_tuner_status()), + ', "schedstatus": ', str(self.get_scheduler_status()), + '}' + ]) + return js + + def get_tuner_status(self): + web_tuner_url = 'http://localhost:' + \ + str(self.config['web']['plex_accessible_port']) + url = (web_tuner_url + '/tunerstatus') + return self.get_url(url) + + @handle_url_except() + def get_url(self, _url): + req = urllib.request.Request(_url) + with urllib.request.urlopen(req) as resp: + result = resp.read() + if result is None: + return 'null' + return result.decode('utf-8') + + def get_scheduler_status(self): + scheduler_db = DBScheduler(self.config) + active_tasks = scheduler_db.get_tasks_by_active() + return json.dumps(active_tasks, default=str) diff --git a/lib/web/pages/index_js.py b/lib/web/pages/index_js.py new file mode 100644 index 0000000000000000000000000000000000000000..fcb27cdaff8ae3d4f094bf24983a67ce8455e190 --- /dev/null +++ b/lib/web/pages/index_js.py @@ -0,0 +1,170 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import lib.common.utils as utils +from lib.common.decorators import getrequest +from lib.db.db_plugins import DBPlugins + + +@getrequest.route('/api/index.js') +def pages_index_js(_webserver): + indexjs = IndexJS(_webserver.config) + _webserver.do_mime_response(200, 'text/javascript', indexjs.get()) + return True + + +class IndexJS: + + def __init__(self, _config): + self.config = _config + self.plugin_db = DBPlugins(_config) + + def check_upgrade_status(self): + plugin_defns = self.plugin_db.get_plugins( + True, None, None) + if plugin_defns: + for plugin_defn in plugin_defns: + latest_version = plugin_defn['version']['latest'] + upgrade_available = '' + if plugin_defn['external'] and latest_version != plugin_defn['version']['current']: + return '$(\"#pluginStatus\").text("Upgrade");' + return '' + + def get(self): + js = ''.join([ + 'var upgrading = "running"; ', + 'var lookup_title = new Map(); ', + 'lookup_title.set("/html/links.html", "Cabernet Links"); ', + 'lookup_title.set("/api/configform?area=general", "Cabernet Settings:Internal"); ', + 'lookup_title.set("/api/configform?area=streams", "Cabernet Settings:Streams"); ', + 'lookup_title.set("/api/configform?area=epg", "Cabernet Settings:EPG"); ', + 'lookup_title.set("/api/configform?area=clients", "Cabernet Settings:Clients"); ', + 'lookup_title.set("/api/configform?area=logging", "Cabernet Settings:Logging"); ', + 'lookup_title.set("/api/channels", "Cabernet Channel Editor"); ', + 'lookup_title.set("/api/schedulehtml", "Cabernet Scheduler"); ', + 'lookup_title.set("/api/datamgmt", "Cabernet Data Management"); ', + 'lookup_title.set("/api/plugins", "Cabernet Plugins"); ', + 'function load_url(url, title) {', + '$(\"#content\").load(url);', + 'document.title = title;', + 'newurl = window.location.pathname+"?content="+encodeURIComponent(url);', + 'window.history.pushState({}, null, newurl);', + '}', + + 'function load_status_url(url) {', + 'if ( upgrading == "running" ) { ', + '$(\"#status\").load(url, function( resp, s, xhr ) {', + 'if ( s == \"error\" ) {', + '$(\"#status\").text( xhr.status + \" \" + xhr.statusText ); ', + '} else {setTimeout(function(){', + 'load_status_url(url);}, 700);', + '}});} else if ( upgrading == "success" ) {$(\"#status\").append(\"Upgrade complete, reload page\");', + ' upgrading = "running";', + '} else {$(\"#status\").append(\"Upgrade aborted\"); upgrading = "running";}};', + + '$(document).ready(setTimeout(function(){', + '$(\'head\').append(\'', + '', + '\');', + + 'if ( !window.location.search ) {', + '$(\'#content\').html(\'' + 'Dashboard', + '', + '', + '', + '', + '\');', + + '$(\'#content\').append(\'
    ', + self.get_version_div(), + '
    ', + '\');', + '} else {', + 'const urlSearchParams = new URLSearchParams(window.location.search);', + 'const params = Object.fromEntries(urlSearchParams.entries());', + 'if (params.content) {' + 'load_url.call(this, params.content, ', + 'lookup_title.get(params.content));', + '}', + '}', + 'logo = getComputedStyle(document.documentElement)', + '.getPropertyValue("--logo-url");', + 'if ( logo == \"\" ) { ', + 'setTimeout(function() {', + 'logo = getComputedStyle(document.documentElement)', + '.getPropertyValue("--logo-url");', + '$(\'#logo\').html(\'', + '\');', + '}, 2000);' + '} else {' + '$(\'#logo\').html(\'', + '\');', + '}', + self.check_upgrade_status(), + '}, 1000));' + ]) + return js + + def get_version_div(self): + manifest_list = self.plugin_db.get_repos(utils.CABERNET_ID) + if not manifest_list: + current_version = utils.VERSION + upgrade_js = '' + else: + current_version = manifest_list[0]['version']['current'] + + next_version = manifest_list[0]['version'].get('next') + if not next_version: + current_version = 'TBD' + next_version = 'TBD' + latest_version = manifest_list[0]['version'].get('latest') + if not latest_version: + current_version = 'TBD' + latest_version = 'TBD' + if current_version == next_version: + upgrade_js = '' + else: + upgrade_js = ''.join([ + 'A new version of Cabernet is available!
    ', + 'Latest Version ', latest_version, '   ', + '', + 'upgrade', + 'Upgrade to ', next_version, '   ' + ]) + + version_js = ''.join([ + '
    ', + 'Version: ', current_version, '
    ', + upgrade_js, + '
    ' + ]) + return version_js diff --git a/lib/web/pages/manifest.py b/lib/web/pages/manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..6b127e354a074813a668a45324a63208d696994e --- /dev/null +++ b/lib/web/pages/manifest.py @@ -0,0 +1,82 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import pathlib +import os + +from lib.common.decorators import getrequest +from lib.web.pages.templates import web_templates +from lib.db.db_plugins import DBPlugins + + +@getrequest.route('/api/manifest') +def get_manifest_data(_webserver): + if 'plugin' not in _webserver.query_data: + pass + elif 'key' not in _webserver.query_data: + pass + elif 'repo' not in _webserver.query_data: + return send_file(_webserver) + else: + return send_file(_webserver) + + _webserver.do_mime_response(404, 'text/html', web_templates['htmlError'].format('404 - Manifest Not Found')) + return False + + +def send_file(_webserver): + repo = _webserver.query_data.get('repo') + plugin = _webserver.query_data['plugin'] + key = _webserver.query_data['key'] + + plugin_db = DBPlugins(_webserver.config) + plugin_defn = plugin_db.get_plugins_by_name(None, repo, plugin) + if not plugin_defn: + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - Plugin Not Found')) + return + + if len(plugin_defn) != 1: + print('TOO MANY PLUGINS') + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - DUPLICATE PLUGINS')) + return + plugin_defn = plugin_defn[0] + if not plugin_defn['version']['installed']: + thumbnail_path = _webserver.config['paths']['thumbnails_dir'] + plugin_id = plugin_defn['id'] + image_path = plugin_defn[key] + full_cache = pathlib.Path( + thumbnail_path, plugin_id, image_path) + + _webserver.do_file_response(200, None, full_cache) + return + else: + try: + base = os.path.dirname(_webserver.plugins.plugins[plugin].plugin_settings[key]).replace('/', '.') + image_filename = os.path.basename(_webserver.plugins.plugins[plugin].plugin_settings[key]) + path_to_image = _webserver.plugins.plugins[plugin].plugin_path + '.' + base + _webserver.do_file_response(200, path_to_image, image_filename) + except KeyError: + _webserver.do_mime_response( + 404, 'text/html', web_templates['htmlError'] + .format('404 - Not Found')) + + diff --git a/lib/web/pages/templates.py b/lib/web/pages/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..7a5fd5efb0b7e1ade9819380397aa06a3a0d348f --- /dev/null +++ b/lib/web/pages/templates.py @@ -0,0 +1,29 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +web_templates = { + + 'htmlError': + """ + + +

    {}

    + + """, + +} diff --git a/lib/web/pages/web_urls.py b/lib/web/pages/web_urls.py new file mode 100644 index 0000000000000000000000000000000000000000..094a1779d13f0178151652a31d2dcd3e6175b477 --- /dev/null +++ b/lib/web/pages/web_urls.py @@ -0,0 +1,34 @@ +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the “Software”), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +from lib.common.decorators import getrequest + + +@getrequest.route('/') +@getrequest.route('/html/index.html') +def root_url(_webserver): + _webserver.send_response(302) + _webserver.send_header('Location', 'html/index.html') + _webserver.end_headers() + + +@getrequest.route('/favicon.ico') +def favicon(_webserver): + _webserver.send_response(302) + _webserver.send_header('Location', 'images/favicon.png') + _webserver.end_headers() diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3740447fd1d983513442aeaba8d2b6f550f6ceeb --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +# location to install plugins diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..fccd4445bcbdfb75c0a5d468321cc036e8834848 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +cryptography +requests +streamlink diff --git a/tvh_main.py b/tvh_main.py new file mode 100644 index 0000000000000000000000000000000000000000..f858a834269aa878d7bfb25a36a2f96876767f15 --- /dev/null +++ b/tvh_main.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +MIT License + +Copyright (C) 2023 ROCKY4546 +https://github.com/rocky4546 + +This file is part of Cabernet + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. +""" + +import os +import sys +from inspect import getsourcefile + +if sys.version_info.major == 2 or sys.version_info < (3, 10): + print('Error: cabernet requires python 3.10+.') + sys.exit(1) + +from lib import main + +if __name__ == '__main__': + + init_path = os.getcwd() + script_dir = os.path.abspath(os.path.dirname(getsourcefile(lambda:0))) + os.chdir(os.path.dirname(os.path.abspath(__file__))) + #script_dir = pathlib.Path(os.path.dirname(os.path.abspath(__file__))) + #print('os.path.realpath', os.path.realpath(__file__)) + #print('os.path.abspath(os.path.dirname',os.path.abspath(os.path.dirname(__file__))) + #print('os.path.dirname',os.path.dirname(sys.argv[0])) + #print('os.getcwd()', os.getcwd()) + #print('getsourcefile', os.path.abspath(os.path.dirname(getsourcefile(lambda:0)))) + main.main(script_dir) + + sys.stderr.flush() + sys.stdout.flush() + os.chdir(init_path) + if ('-r' in sys.argv) or ('--restart' in sys.argv): + pass + else: + sys.argv.append('-r') + sys.argv.append('1') + os.execl(sys.executable, sys.executable, *sys.argv)