diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d6bc4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python files +*.py[ocd] + +## generic files to ignore +*~ +*.lock +*.DS_Store +*.swp +*.out + +# TraktForVLC files +*.luac +build/ +dist/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..fbbf46c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,323 @@ +language: python + +branches: + except: + - latest + - latest-tmp + +matrix: + fast_finish: true + include: + - os: linux + python: 2.7 + - os: osx + language: generic + env: PYTHON=2.7.14 + +env: + global: + - PIPENV=9.0.3 + - secure: "CnchGXzH12uHNHwVeSdfyi/UBoHONNogxVaM+fIzcZXadiK3mm6mnjoBSXiFabNAgmMr20BHZ5OSB8qZLX5L0jMxrH3zfvlTUFivibL+IHAqPC/Jt1EpABDxbE+BMLVcpJBOVztDpACNDomnVX1X+mLhG2lbcD0x/XlZJugpB68=" + +before_install: + # Need to disable the boto configuration before starting a sudo-enabled + # build in Travis, or the tests will fail (as one of the modules loads + # boto) + - export BOTO_CONFIG=/dev/null + # If we did not define the python version in the environment, set it as + # being the one provided by Travis + - if [ -z "${PYTHON}" ]; then + PYTHON="${TRAVIS_PYTHON_VERSION}"; + fi + # If we are on OSX, we need to devine a number of aliases for GNU commands, + # as well as print the OSX software information + - if [[ "${TRAVIS_OS_NAME}" == "osx" ]]; then + sw_vers; + function sort() { $(which gsort) "$@"; }; + function timeout() { $(which gtimeout) "$@"; }; + export HOMEBREW_NO_AUTO_UPDATE=1; + brew install gnu-sed --with-default-names; + fi + # If we are on pypy, determine which is the latest version by using the + # bitbucket's API on pypy's repository + - if [[ "${PYTHON}" =~ ^pypy ]]; then + if [ "${PYTHON}" == "pypy" ]; then + PYTHON=pypy2; + fi; + PYTHON=$( + curl "https://api.bitbucket.org/2.0/repositories/pypy/pypy/refs/tags" 2>/dev/null | + grep -oE "release-${PYTHON}[a-zA-Z0-9._-]*" | + grep -- '-v' | + sed -e 's/^release-//g' -e 's/\-v/\-/g' | + sort -t'-' -k1,1Vr -k2,2Vr -u | + head -n1 + ); + if [ -z "${PYTHON}" ]; then + echo "ERROR - No PYPY version found."; + exit 1; + fi; + echo "PyPy version - $PYTHON"; + fi + # If we are on OSX, or if we want to use pypy, we need to install the + # version of python we want. We are going to use pyenv for that, that will + # take care of downloading and compiling python as needed. + - if [[ "${TRAVIS_OS_NAME}" == "osx" ]] || [[ "${PYTHON}" =~ ^pypy ]]; then + set -x; + + rm -rf ~/.pyenv; + + if [[ "${TRAVIS_OS_NAME}" == "osx" ]]; then + export PYTHON_CONFIGURE_OPTS="--enable-framework"; + else + export PYTHON_CONFIGURE_OPTS="--enable-shared"; + fi; + + git clone --depth 1 https://github.com/pyenv/pyenv ~/.pyenv; + PYENV_ROOT="$HOME/.pyenv"; + PATH="$PYENV_ROOT/bin:$PATH"; + eval "$(pyenv init -)"; + + pyenv install ${PYTHON} || exit 1; + pyenv global ${PYTHON} || exit 1; + + pyenv rehash; + export PATH=$(python -c "import site, os; print(os.path.join(site.USER_BASE, 'bin'))"):$PATH; + python -m pip install --user pipenv==${PIPENV}; + + echo $PATH; + + set +x; + else + pip install pipenv==${PIPENV}; + fi + # Print Python version + - python --version + - pipenv --version + # Install all dependencies + - python --version; which python + - echo $PATH; pipenv install --dev + # If the build should lead to a deployment, check that the tag is valid, + # else, check what the current version will be + - if [ -n "${TRAVIS_TAG}" ]; then + pipenv run ./version.py check-tag --tag=${TRAVIS_TAG} && + environment=$(pipenv run ./version.py environment --version=${TRAVIS_TAG}) && + eval "$environment"; + else + environment=$(pipenv run ./version.py environment) && + eval "$environment"; + fi && + printenv | grep '^TRAKT_VERSION' | sort + # Set the binary name + - TRAKT_HELPER_BIN=$(echo "TraktForVLC_${TRAKT_VERSION}_${TRAVIS_OS_NAME}" | sed -r 's/[^a-zA-Z0-9_.-]+/./g') + # Install VLC + - if [[ "${TRAVIS_OS_NAME}" == "osx" ]]; then + brew cask install vlc; + else + sudo apt-get install -y vlc; + fi + # Print VLC version + - vlc --version + +install: + # Set the version in the Python and LUA scripts + - pipenv run ./version.py set --version=${TRAKT_VERSION} + # Compile the Lua scripts + - vlc -I luaintf --lua-intf luac + --lua-config 'luac={input="trakt.lua",output="trakt.luac"}' + # Then prepare the binary file + - pipenv run pyinstaller + --onedir --onefile + --name=${TRAKT_HELPER_BIN} + --hidden-import=concurrent.futures + --add-data=trakt.luac:. + --console trakt_helper.py + # Test the binary by first checking if the --version command returns properly + - dist/${TRAKT_HELPER_BIN} --version + # Then print the help message + - dist/${TRAKT_HELPER_BIN} --help + +script: + # Prepare the variables to know where to check for the installation + - if [[ "${TRAVIS_OS_NAME}" == "osx" ]]; then + CONFIG="${HOME}/Library/Application Support/org.videolan.vlc"; + LUA_USER="${CONFIG}/lua"; + LUA_SYSTEM="/Applications/VLC.app/Contents/MacOS/share/lua"; + else + CONFIG="${HOME}/.config/vlc"; + LUA_USER="${HOME}/.local/share/vlc/lua"; + LUA_SYSTEM="$(python -c "import glob; print( + glob.glob('/usr/lib/*/vlc/lua') + + glob.glob('/usr/lib/vlc/lua'))[0]")"; + fi + + ############################################################################ + # TEST INSTALLATION LOCALLY + ############################################################################ + # Install with default parameters + - dist/${TRAKT_HELPER_BIN} --debug install --yes --no-init-trakt-auth + # Check that the helper has been installed + - ls -ls "${LUA_USER}/trakt_helper" + # And that the Lua interface is also installed + - ls -ls "${LUA_USER}/intf/trakt.luac" + # Run vlc to check that TraktForVLC is ready + - vlc >/tmp/vlc.out 2>&1 & sleep 5; kill $! + - sleep 5; cat /tmp/vlc.out + # Check that it found the helper + - | + helper=$(cat /tmp/vlc.out | perl -ne ' + if ($_ =~ m/\[trakt\] lua interface: helper:/g) { + $_ =~ s/^.*\[trakt\] lua interface: helper: //g; + print; + }') + test "$helper" == "${LUA_USER}/trakt_helper" + if [ $? -ne 0 ]; then + echo "Helper is '${helper}' instead of '${LUA_USER}/trakt_helper'" + cat /tmp/vlc.out + false + else + echo "Helper '${helper}' was found" + fi + # And that it was requesting for Trakt.tv access + - | + test "$(cat /tmp/vlc.out | + perl -ne 'print "OK" if $_ =~ m/^\s*TraktForVLC is not setup/' + )" == "OK" + if [ $? -ne 0 ]; then + echo "TraktForVLC is NOT requesting for Trakt.tv access :(" + false + else + echo "TraktForVLC is requesting for Trakt.tv access :)" + fi + # And that it did not fail before we stopped + - | + test "$(cat /tmp/vlc.out | + perl -ne 'print "NOK" if $_ =~ m/^\s*TraktForVLC setup failed/' + )" != "NOK" + if [ $? -ne 0 ]; then + echo "TraktForVLC failed (setup fail) before we stopped VLC :(" + false + else + echo "TraktForVLC did not fail (setup fail) before we stopped VLC :)" + fi + # Test the update tool + - | + vlc --lua-config "trakt={check_update={file=\"$(pwd)/dist/${TRAKT_HELPER_BIN}\",wait=30,output=\"/tmp/update_output.log\"}}"; + ls "/tmp/update_output.log"; + cat "/tmp/update_output.log" + # Then uninstall + - "\"${LUA_USER}/trakt_helper\" --debug uninstall --yes" + # And check that the files are not there + - test \! -f "${LUA_USER}/trakt_helper" + - test \! -f "${LUA_USER}/intf/trakt.luac" + + ############################################################################ + # IN BETWEEN CLEANING + ############################################################################ + - rm /tmp/vlc.out + + ############################################################################ + # TEST INSTALLATION SYSTEM-WIDE + ############################################################################ + # Install with default parameters + - sudo dist/${TRAKT_HELPER_BIN} --debug install --yes --system --no-init-trakt-auth + # Check that the helper has been installed + - ls -ls "${LUA_SYSTEM}/trakt_helper" + # And that the Lua interface is also installed + - ls -ls "${LUA_SYSTEM}/intf/trakt.luac" + # Run vlc to check that TraktForVLC is ready + - vlc >/tmp/vlc.out 2>&1 & sleep 5; kill $! + - sleep 5; cat /tmp/vlc.out + # Check that it found the helper + - | + helper=$(cat /tmp/vlc.out | perl -ne ' + if ($_ =~ m/\[trakt\] lua interface: helper:/g) { + $_ =~ s/^.*\[trakt\] lua interface: helper: //g; + print; + }') + test "$helper" == "${LUA_SYSTEM}/trakt_helper" + if [ $? -ne 0 ]; then + echo "Helper is '${helper}' instead of '${LUA_SYSTEM}/trakt_helper'" + false + else + echo "Helper '${helper}' was found" + fi + # And that it was requesting for Trakt.tv access + - | + test "$(cat /tmp/vlc.out | + perl -ne 'print "OK" if $_ =~ m/^\s*TraktForVLC is not setup/' + )" == "OK" + if [ $? -ne 0 ]; then + echo "TraktForVLC was NOT requesting for Trakt.tv access :(" + false + else + echo "TraktForVLC was requesting for Trakt.tv access :)" + fi + # And that it did not fail before we stopped + - | + test "$(cat /tmp/vlc.out | + perl -ne 'print "NOK" if $_ =~ m/^\s*TraktForVLC setup failed/' + )" != "NOK" + if [ $? -ne 0 ]; then + echo "TraktForVLC failed (setup fail) before we stopped VLC :(" + false + else + echo "TraktForVLC did not fail (setup fail) before we stopped VLC :)" + fi + # Then uninstall + - "sudo \"${LUA_SYSTEM}/trakt_helper\" --debug uninstall --yes --system" + # And check that the files are not there + - test \! -f "${LUA_SYSTEM}/trakt_helper" + - test \! -f "${LUA_SYSTEM}/intf/trakt.luac" + +before_deploy: + - | + if [ -n "${TRAVIS_TAG}" ]; then + RELEASE_DRAFT=false; + if [ -z "${TRAKT_VERSION_PRE_TYPE}" ]; then + RELEASE_PRE=false; + else + RELEASE_PRE=true; + fi; + else + TRAKT_VERSION_NAME="${TRAVIS_BRANCH}-branch"; + RELEASE_DRAFT=true; + RELEASE_PRE=false; + TRAKT_VERSION_DESCRIPTION="Draft of release for branch ${TRAVIS_BRANCH}. ${TRAKT_VERSION_DESCRIPTION}"; + fi + - | + echo "RELEASE NAME: ${TRAKT_VERSION_NAME}" + echo "RELEASE DESCRIPTION: ${TRAKT_VERSION_DESCRIPTION}" + echo "IS PRE RELEASE ? ${RELEASE_PRE}" + echo "IS RELEASE DRAFT ? ${RELEASE_DRAFT}" + +deploy: + # Need to duplicate the release as travis do not manage properly the + # conversion of 'true' and 'false' strings to booleans + - provider: releases + name: ${TRAKT_VERSION_NAME} + body: ${TRAKT_VERSION_DESCRIPTION} + api_key: ${GITHUB_TOKEN} + file: dist/${TRAKT_HELPER_BIN} + draft: false + prerelease: true + skip_cleanup: true + on: + condition: '-n "${TRAKT_VERSION_PRE_TYPE}"' + tags: true + - provider: releases + name: ${TRAKT_VERSION_NAME} + body: ${TRAKT_VERSION_DESCRIPTION} + api_key: ${GITHUB_TOKEN} + file: dist/${TRAKT_HELPER_BIN} + draft: false + prerelease: false + skip_cleanup: true + on: + condition: '-z "${TRAKT_VERSION_PRE_TYPE}"' + tags: true + +after_success: + - if [ -z "${TRAVIS_TAG}" ] && [ "${TRAVIS_BRANCH}" == "master" ] && [ "${TRAVIS_PULL_REQUEST}" == "false" ]; then + pipenv install scikit-ci-addons && + pipenv run ci_addons publish_github_release --prerelease-packages "dist/${TRAKT_HELPER_BIN}" --prerelease-packages-clear-pattern "TraktForVLC_*_${TRAVIS_OS_NAME}" --prerelease-packages-keep-pattern "${TRAKT_HELPER_BIN}" --prerelease-sha "${TRAVIS_BRANCH}" --re-upload "${TRAVIS_REPO_SLUG}"; + fi diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..8e9a153 --- /dev/null +++ b/Pipfile @@ -0,0 +1,22 @@ +[[source]] + +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + + +[dev-packages] + +pyinstaller = "*" + + +[packages] + +requests = "*" +imdbpie = "*" +tvdb-api = "*" +fuzzywuzzy = "*" +python-levenshtein = "*" +pytz = "*" +futures = "*" +tmdbsimple = "*" diff --git a/README.md b/README.md new file mode 100644 index 0000000..34f5f41 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +TraktForVLC [![Travis Build Status](https://travis-ci.org/XaF/TraktForVLC.svg?branch=master)](https://travis-ci.org/XaF/TraktForVLC) [![AppVeyor Build status](https://ci.appveyor.com/api/projects/status/e1ie51bwhbki60ns/branch/master?svg=true)](https://ci.appveyor.com/project/XaF/traktforvlc/branch/master) +=========== + +TraktForVLC allows scrobbling VLC content to [trakt.tv](https://trakt.tv/). + +TraktForVLC 2.x works by using together a lua VLC module and a Python helper script in order to find information on the media you are watching. +Contrary to previous versions of TraktForVLC, the VLC module allows for a direct binding in your media activity (play, pause, stop) and thus to take immediate actions on your [trakt.tv](https://trakt.tv/) account. + + +## Table of Contents + +* [Information](#information) + * [Credits](#credits) + * [License](#license) + * [External libraries and resources](#external-libraries-and-resources) +* [Installation](#installation) + * [Finding the installers](#finding-the-installers) + * [Installation per OS](#installation-per-os) + * [Initial configuration](#initial-configuration) +* [Configuration file](#configuration-file) + * [Location](#location) + * [Sections](#sections) +* [Issues](#issues) + + +## Information +### Credits + +This version of TraktForVLC has been rewritten from scratch. + +All licensing and used code is mentionned directly in the code source, except for the external libraries and resources information provided below. + +### License + +Copyright (C) 2017-2018 Raphaël Beamonte <> + +This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. [See the GNU General Public License for more details](https://www.gnu.org/licenses/gpl-2.0.html). + +### External libraries and resources + +The `vlc-logo-1s.mp4` file in this repository, also distributed in the binary files for Windows, contains a VLC logo that is not owned by this project. This logo is owned by its authors. VideoLAN can be reached on [their website](https://www.videolan.org/). + +TraktForVLC uses external libraries and APIs for the media resolution: +* The [OpenSubtitles](https://www.opensubtitles.org/) API +* [The TVDB](https://www.thetvdb.com/) API (through the [tvdb_api](https://github.com/dbr/tvdb_api) Python package); TV information is provided by TheTVDB.com, but we are not endorsed or certified by TheTVDB.com or its affiliates. +* The [IMDB](https://www.imdb.com/) API (through the [imdbpie](https://pypi.python.org/pypi/imdbpie) Python package) +* The [TMDb](https://www.themoviedb.org/) API (through the [tmdbsimple](https://github.com/celiao/tmdbsimple) Python package); This product uses the TMDb API but is not endorsed or certified by TMDb. + +TraktForVLC is not endorsed or certified by any of these external libraries and resources owners. + + +## Installation + +### Finding the installers + +Installers are provided in the [GitHub release section](https://github.com/XaF/TraktForVLC/releases). +The [latest release is available here](https://github.com/XaF/TraktForVLC/releases/tag/latest); however, we highly recommend using a stable release if you are not sure of what you are doing. + +Once you have found the installer that corresponds to your operating system and downloaded it, simply running it will allow you to install TraktForVLC with the default parameters. + +### Installation per OS + +On Windows, you can right-click and run the file with the administrator privileges to start the install process. The administrator privileges are required as, on Windows, the Python helper is installed as a Windows service. + +On Linux and MacOS, make the file executable (using `chmod +x file`) and then run it in the command line (`./TraktForVLC_version_os`). + +### Initial configuration + +At the end of the first installation, the initial configuration will automatically be started by the installer. +This process aims at authorizing TraktForVLC to access your [trakt.tv](https://trakt.tv/) account. + +Depending on your OS, the process is presented a bit differently: +* On Linux and MacOS: a message giving instructions will appear directly at the end of the installation logs +* On Windows: a VLC window will be started and the screen will soon show a message giving instructions + +The message shown should look like the following: +``` +TraktForVLC is not setup with Trakt.tv yet! +-- +PLEASE GO TO https://trakt.tv/activate +AND ENTER THE FOLLOWING CODE: +9EC4F248 +``` + +The instructions are to go to a URL and enter a given code. Please do while keeping the process (VLC window on Windows) active, and wait after entering the code and validating the authorization that the process stops by itself. +If you stops the process before then, you will need to restart the initial configuration manually. + +To restart the initial configuration process manually, the following command might be used: `./TraktForVLC_version_os init_trakt_auth` + +## Configuration file + +The configuration file for TraktForVLC is a JSON file named `trakt_config.json`. + +### Location +The `trakt_config.json` file is located in your VLC configuration directory. Depending on your OS, the VLC configuration directory will be located at the following places: +* Linux: `~/.config/vlc` +* MacOS: `~/Library/Application Support/org.videolan.vlc` +* Windows: `%APPDATA%/vlc` (where `%APPDATA%` is the value of the `APPDATA` environment variable) + +### Sections + +* `config_version`: The version of TraktForVLC that generated the configuration file (present for retrocompatibility purposes) +* `cache`: Configuration relative to the media cache used by TraktForVLC + * `delay`: The delays for operations performed on the media cache + * `save`: Delay (in seconds) between save operations on the cache (default: `30`) + * `cleanup`: Delay (in seconds) between cleanup operations on the cache (default: `60`) + * `expire`: Time (in seconds) after which an unused entry in the cache expires (default: `2592000` - 30 days) +* `media`: Configuration relative to media resolution and scrobbling + * `info`: Configuration relative to media resolution + * `max_try`: Maximum number of times we will try to resolve the current watched item through IMDB (default: `10`) + * `try_delay_factor`: Delay factor (in seconds) between try attempts; if `try_delay_factor` is `f` and attempt is `n`, next try will be after `n*f` seconds (default: `30`) + * `start`: Configuration relative to media watching status + * `time`: Time after which a media will be marked as being watched on [trakt.tv](https://trakt.tv/) (default: `30`) + * `percent`: Percentage of the media watched after which the media will be marked as being watched on [trakt.tv](https://trakt.tv/) (default: `.25` - 0.25%) + * `movie`: Whether or not to mark movies as being watched (default: `true`) + * `episode`: Whether or not to mark episodes as being watched (default: `true`) + * `stop`: Configuration relative to media scrobbling + * `watched_percent`: The minimum watched percent for a media to be scrobbled as seen on [trakt.tv](https://trakt.tv); i.e. you must have watched at least that percentage of the media, for it to be scrobbled (default: `50`) + * `percent`: The minimum percentage of the media duration at which you must currently be for the media to be scrobbled as seen (if the media has a duration of `100mn`, and you configured the `percent` as `90`, you must at least be at the `90th` minute of the media) (default: `90`) + * `movie`: Whether or not to scrobble movies as watched (default: `true`) + * `episode`: Whether or not to scrobble episodes as watched (default: `true`) + * `check_unprocessed_delay`: Delay (in seconds) between checks for medias that should be scrobbled as watched but have not been for any reason (no internet connection, media not identified yet, etc.) (default: `120`) +* `helper`: Configuration relative to the helper tool + * `mode`: The mode of the helper. Can be one of `standalone` or `service` (default: `standalone`) + * `service`: The service configuration, when the helper is installed as a service + * `host`: The host on which the service is listening (default: `localhost`) + * `port`: The port on which the service is listening (default: `1984`) + * `update`: To configure the automatic updates for TraktForVLC + * `check_delay`: The delay - in seconds - in between checks for new updates (default: `86400` - 24 hours) + * `release_type`: The type of releases to look for. Can be one of `stable`, `rc`, `beta`, `alpha`, `latest` (default: `stable`) + * `action`: The action to perform automatically when a new release is found. Can be one of `install`, `download` and `check` (default: `install`) + + +## Issues +Please use the [GitHub integrated issue tracker](https://github.com/XaF/TraktForVLC/issues) for every problem you can encounter. Please **DO NOT** use my email for issues or walkthrough. + +When submitting an issue, please submit a VLC logfile showing the error. You can start VLC in debug mode (`--vv` option) to obtain more thorough logs. + +> **Please** be careful to remove any personnal information that might still be in the logfile (password, identification token, ...) before putting your file online. diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..a814e62 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,401 @@ +version: '{branch}.{build}' +environment: + matrix: + - PYTHON: "2.7" + PYTHON_INSTALL: "2.7.14" + VLC_INSTALL: "3.0.1" + PIPENV_INSTALL: "9.0.3" + should_deploy: yes + GITHUB_TOKEN: + secure: 3Shl8s7OoiyYyi5Zmmr4XhBrQyHTS4A4jQL6uBcEb8UbO7USkqekpice9EUNU32E + APPVEYOR_API_TOKEN: + secure: kkr9LewGDRWYZSggRa2+/39A8sPEJuRVVYZhecuuNDg= + +platform: + - x86 + - x64 + +init: + # Inspired from scikit-ci-addons's cancel-queued-build.ps1 to cancel + # the queued builds that match latest/latest-tmp when using rolling + # releases for the latest tag. This also integrates the fact of + # cancelling a build if there is a more recent one for the same + # pull request. + # The AppVeyor 'rollout builds' option is supposed to serve the same + # purpose but it is problematic because it tends to cancel builds pushed + # directly to master instead of just PR builds (or the converse). + - ps: | + Function CancelBuild { + $version = $args[0] + $url = "https://ci.appveyor.com/api/builds/${env:APPVEYOR_ACCOUNT_NAME}/${env:APPVEYOR_PROJECT_SLUG}/${version}" + Invoke-RestMethod $url -Method Delete -Headers @{"Authorization" = "Bearer ${env:APPVEYOR_API_TOKEN}"}; + Write-Host " Build version $version - cancelled" + } + Function DeleteBuild { + if ($args[0] -eq 'queued' -or $args[0] -eq 'running') { + CancelBuild $args[1] + } + $buildId = $args[2] + $url = "https://ci.appveyor.com/api/builds/${buildId}" + Invoke-RestMethod $url -Method Delete -Headers @{"Authorization" = "Bearer ${env:APPVEYOR_API_TOKEN}"}; + Write-Host " Build ID $buildID - deleted" + } + $action_self = $false; + (Invoke-RestMethod https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | %{ + if ($_.isTag) { + if ($_.tag -match '^latest(-tmp)?$') { + if ($_.buildId -eq $env:APPVEYOR_BUILD_ID) { + $action_self = 'delete' + } else { + Write-Host "Found build version $($_.version) (ID $($_.buildId)) for tag $($_.tag)" + DeleteBuild $_.status $_.version $_.buildId + } + } + } elseif (-not $action_self -and $env:APPVEYOR_PULL_REQUEST_NUMBER -and $_.pullRequestId -and $_.pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -lt $_.buildNumber) { + $action_self = 'cancel' + } + } + if ($action_self -eq 'delete') { + Write-Host "Build is for an exception tag. Deleting self." + DeleteBuild 'running' $env:APPVEYOR_BUILD_VERSION $env:APPVEYOR_BUILD_ID + } elseif ($action_self -eq 'cancel') { + Write-Host "There are newer queued builds for this pull request, cancelling build."; ` + CancelBuild $env:APPVEYOR_BUILD_VERSION + } + +install: + # Set the home for the Python version + - set "PYTHON_HOME=C:\\Python%PYTHON:.=%" + - if [%PLATFORM%]==[x64] set "PYTHON_HOME=%PYTHON_HOME%-x64" + + # Fix for x64 msvc9compiler.py + - if [%PLATFORM%]==[x64] ( + if not exist "C:\Program Files (x86)\Microsoft Visual Studio 10.0\VC\bin\amd64\vcvars64.bat" ( + echo CALL "C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\SetEnv.cmd" /x64 > "C:\Program Files (x86)\Microsoft Visual Studio 10.0\VC\bin\amd64\vcvars64.bat" & + echo Setting vcvars64.bat (fix for msvc9compiler.py^^^) + ) else ( + echo vcvars64.bat already exists. + ) + ) + + # Print environment information + - "echo Environment: Python %PYTHON% / Platform %PLATFORM% / %PYTHON_HOME%" + + # Install and configure Python in the path to use the needed version + - if not exist "%PYTHON_HOME%" ( set "NEEDINSTALL=True" ) else ( set "NEEDINSTALL=False" ) + - "echo Does Python %PYTHON% (%PLATFORM%) need to be installed? %NEEDINSTALL%" + - if [%NEEDINSTALL%]==[True] ( + if [%PLATFORM%]==[x64] ( + set "PYTHON_DL=python-%PYTHON_INSTALL%.amd64.msi" + ) else ( + set "PYTHON_DL=python-%PYTHON_INSTALL%.msi" + ) + ) + - if [%NEEDINSTALL%]==[True] ( + echo Downloading https://www.python.org/ftp/python/%PYTHON_INSTALL%/%PYTHON_DL% & + appveyor DownloadFile https://www.python.org/ftp/python/%PYTHON_INSTALL%/%PYTHON_DL% & + echo Installing %PYTHON_DL% in %PYTHON_HOME% & + msiexec /i %PYTHON_DL% /qn TARGETDIR=%PYTHON_HOME% + ) + - set "PATH=%PYTHON_HOME%;%PYTHON_HOME%\\Scripts;%PATH%" + - "python --version" + - "python -c \"import struct; print('Architecture: {0}bit'.format(struct.calcsize('P') * 8))\"" + + # Check installed pip version + - "pip --version" + + # Install dependencies + - "pip install pipenv==%PIPENV_INSTALL%" + - "pipenv --version" + - "pipenv install --dev" + + # If the build should lead to a deployment, check that the tag is valid, + # else, check what the current version will be + - ps: | + if ($env:APPVEYOR_REPO_TAG -eq "true") { + pipenv run "${env:APPVEYOR_BUILD_FOLDER}\version.py" 'check-tag' '--tag' "${env:APPVEYOR_REPO_TAG_NAME}" + Invoke-Expression "$(pipenv run "${env:APPVEYOR_BUILD_FOLDER}\version.py" 'environment' '--version' "${env:APPVEYOR_REPO_TAG_NAME}")" + } else { + Invoke-Expression "$(pipenv run "${env:APPVEYOR_BUILD_FOLDER}\version.py" 'environment')" + } + printenv | grep '^TRAKT_VERSION' | sort + # Set the binary name + - ps: | + $env:TRAKT_HELPER_BIN = "TraktForVLC_${env:TRAKT_VERSION}_windows_${env:PLATFORM}.exe" + $env:TRAKT_HELPER_BIN = $env:TRAKT_HELPER_BIN -replace "[^a-zA-Z0-9_.-]+", "." + + # Install VLC + - if [%PLATFORM%]==[x64] ( + set "VLC_PLATFORM=win64" + ) else ( + set "VLC_PLATFORM=win32" + ) + - set "VLC_INST_EXE=vlc-%VLC_INSTALL%-%VLC_PLATFORM%.exe" + - set "VLC_DL=http://download.videolan.org/pub/videolan/vlc/%VLC_INSTALL%/%VLC_PLATFORM%/%VLC_INST_EXE%" + - echo Downloading %VLC_DL% & + appveyor DownloadFile %VLC_DL% + - echo Installing %VLC_INST_EXE% & + "%APPVEYOR_BUILD_FOLDER%\\%VLC_INST_EXE%" /L=1033 /S /NCRC + # Check VLC version information once installed + - ps: | + if (${env:PLATFORM} -eq "x64" -OR ${env:ProgramFiles(x86)} -eq $null) { + $env:VLCPATH = "${env:ProgramFiles}" + } else { + $env:VLCPATH = "${env:ProgramFiles(x86)}" + } + $env:VLCPATH = "${env:VLCPATH}\VideoLAN\VLC" + $env:VLCEXE = "${env:VLCPATH}\vlc.exe" + $vlcVersion = Start-Process "${env:VLCEXE}" '--version' -PassThru + $wshell = New-Object -ComObject wscript.shell + $wshell.AppActivate('VLC media player') + Start-Sleep -s 1 + $wshell.SendKeys('~') + Start-Sleep -s 1 + Get-Content "vlc-help.txt" + # We need to run VLC once, or next time we'll try to call it, we'll block on + # it (it opens a window on first install on Windows...) + - ps: | + $vlc = Start-Process "${env:VLCEXE}" -PassThru + $wshell = New-Object -ComObject wscript.shell + $wshell.AppActivate('VLC media player') + Start-Sleep -s 1 + $wshell.SendKeys('~') + Start-Sleep -s 1 + Stop-Process $vlc + +build_script: + # Set the version in the Python and LUA scripts + - "pipenv run \"%APPVEYOR_BUILD_FOLDER%\\version.py\" set --version=%TRAKT_VERSION%" + # Compile the Lua scripts + - ps: | + & "${env:VLCEXE}" -I luaintf --lua-intf luac --lua-config "luac={input='trakt.lua',output='trakt.luac'}" + # Then prepare the exe file + - "pipenv run pyinstaller --onedir --onefile --name=%TRAKT_HELPER_BIN% --hidden-import=concurrent.futures --add-data=trakt.luac;. --add-data=vlc-logo-1s.mp4;. --console trakt_helper.py" + # Test the exe file by first checking if the --version command returns properly + - "\"%APPVEYOR_BUILD_FOLDER%\\dist\\%TRAKT_HELPER_BIN%\" --version" + # Then print the help message + - "\"%APPVEYOR_BUILD_FOLDER%\\dist\\%TRAKT_HELPER_BIN%\" --help" + +test_script: + # Prepare the variables to know where to check for the installation + - ps: | + $env:CONFIG = "${env:APPDATA}\vlc" + $env:LUA_USER = "${env:CONFIG}\lua" + $env:LUA_SYSTEM = "${env:VLCPATH}\lua" + + # Prepare the function to get VLC's output; because we are on + # windows, output is not returned when running a windowed + # application, we will thus run VLC through the helper, and + # kill it after a few seconds; it happens that even after doing + # that, the vlc-output file stays empty. We will thus wait, and + # if after 30sec the file is still empty, try again. This will + # only be tried 3 times though. + - ps: | + Function GetVLCOutput { + $logFile = $args[0] + $max_retry_vlc = 3 + + while ($true) { + if (Test-Path "$logFile") { + try { + TASKKILL /IM 'vlc.exe' /T /F 2>$null + } catch {} + Remove-Item "$logFile" + } + + $vlc = Start-Process "${env:APPVEYOR_BUILD_FOLDER}\dist\${env:TRAKT_HELPER_BIN}" 'runvlc' -PassThru -RedirectStandardOutput "$logFile" + Start-Sleep -s 5 + TASKKILL /IM 'vlc.exe' + $max_check_file = 6 + $more = '' + while ($max_check_file -gt 0 -and (Get-Content "$logFile") -eq $null) { + echo "Waiting 5 ${more}seconds for VLC Output to be filled" + Start-Sleep -s 5 + $more = 'more ' + $max_check_file -= 1 + } + if ((Get-Content "$logFile") -eq $null) { + if ($max_retry_vlc -gt 0) { + $max_retry_vlc -= 1 + } else { + Throw "Max number of retries reached, still not able to get any data in the VLC log output; aborting."; + } + } else { + return + } + } + } + + # Prepare the function to check VLC output and verify that it was + # correctly configured with TraktForVLC, and that TraktForVLC has + # the right configuration and is requesting for Trakt.tv access + - ps: | + Function CheckVLCOutput { + $logFile = $args[0] + $helper = $args[1] + + $matches = Select-String -Path "$logFile" -Pattern '\[trakt\] lua interface: helper: (.*)$' + if ($matches.Count -eq 0) { + Throw "The line declaring the helper was not found." + } elseif ($matches.Matches.Groups[1].Value -ne "$helper") { + Throw "The helper found is located at $($matches.Matches.Groups[1].Value) instead of $helper" + } else { + echo "The helper was correctly found: $($matches.Matches.Groups[1].Value)" + } + $matches = Select-String -Path "$logFile" -Pattern '^TraktForVLC is not setup' + if ($matches.Count -eq 0) { + Throw "TraktForVLC was not requesting for Trakt.tv access :(" + } else { + echo "TraktForVLC was requesting for Trakt.tv access :)" + } + $matches = Select-String -Path "$logFile" -Pattern '^TraktForVLC setup failed' + if ($matches.Count -ne 0) { + Throw "TraktForVLC failed (setup fail) before we stopped VLC :(" + } else { + echo "TraktForVLC did not fail (setup fail) before we stopped VLC :)" + } + } + + # Prepare the function to both get VLC output log and check it, + # and allow for a number of errors before raising + - ps: | + Function CheckVLCWithTrakt { + $logFile = $args[0] + $helper = $args[1] + + $max_try = 2 + for ($i = 0; $i -lt $max_try; $i++) { + echo "Checking VLC with Trakt ($($i+1)/$max_try)" + + GetVLCOutput "$logFile" + + ls "$logFile" + Get-Content "$logFile" + + try { + CheckVLCOutput "$logFile" "$helper" + } catch { + if ($i -eq ($max_try - 1)) { + throw + } + continue + } + + return + } + } + + ############################################################################ + # TEST INSTALLATION AS A SERVICE (SYSTEM) + ############################################################################ + # Install with default parameters + - "\"%APPVEYOR_BUILD_FOLDER%\\dist\\%TRAKT_HELPER_BIN%\" --debug install --service --yes --no-init-trakt-auth" + # Then we need to check if everything was installed as we would expect + - ps: | + ls "${env:LUA_SYSTEM}\trakt_helper.exe" + ls "${env:LUA_SYSTEM}\intf\trakt.luac" + # Check that the JSON file exists, and that it has the service configuration + - ps: | + python -c "import json, os + fpath = '{}\\vlc\\trakt_config.json'.format(os.getenv('APPDATA')) + with open(fpath, 'r') as f: + d = json.load(f) + if 'helper' not in d: + raise RuntimeError('helper information not found') + if 'mode' not in d['helper'] or d['helper']['mode'] != 'service': + raise RuntimeError('helper mode not set to service ({})'.format( + d['helper'].get('mode'))) + if 'service' not in d['helper']: + raise RuntimeError('helper service information not found') + if 'host' not in d['helper']['service'] or d['helper']['service']['host'] != 'localhost': + raise RuntimeError('helper service host not set correctly ({})'.format( + d['helper']['service'].get('host'))) + if 'port' not in d['helper']['service'] or d['helper']['service']['port'] != 1984: + raise RuntimeError('helper service port not set correctly ({})'.format( + d['helper']['service'].get('port')))" + # Check that VLC is correctly configured with TraktForVLC + - ps: CheckVLCWithTrakt "${env:APPVEYOR_BUILD_FOLDER}\vlc-output.log" "${env:LUA_SYSTEM}\trakt_helper.exe" + # Test the update tool + - ps: | + $vlc_log = "${env:APPVEYOR_BUILD_FOLDER}\update_output_vlc.log" + $update_log_file = "${env:APPVEYOR_BUILD_FOLDER}\update_output.log" + $trakt_config = "trakt={check_update={file=`"`"`"`"${env:APPVEYOR_BUILD_FOLDER}\dist\${env:TRAKT_HELPER_BIN}`"`"`"`",wait=30,output=`"`"`"`"${update_log_file}`"`"`"`"}}" + Start-Process "${env:APPVEYOR_BUILD_FOLDER}\dist\${env:TRAKT_HELPER_BIN}" -ArgumentList 'runvlc','--','--lua-config',"$($trakt_config -replace '\\','\\')" -PassThru -RedirectStandardOutput "$vlc_log" + Start-Sleep -s 60 + ls "$vlc_log" + cat "$vlc_log" + ls "$update_log_file" + cat "$update_log_file" + # Then uninstall + - "\"%LUA_SYSTEM%\\trakt_helper.exe\" --debug uninstall --service --yes" + # And check that the files are not there + - ps: | + $files = "${env:LUA_SYSTEM}\trakt_helper.exe","${env:LUA_SYSTEM}\intf\trakt.luac" + $not_deleted = $false + ForEach ($f in $files) { + if (Test-Path "$f") { + Write-Error "File $f exists but should have been deleted" -Category ResourceExists + $not_deleted = $true + } else { + echo "File $f has been deleted properly" + } + } + if ($not_deleted) { + Throw "Some files have not been cleaned-up during uninstall" + } + +artifacts: + - path: 'dist\$(TRAKT_HELPER_BIN)' + name: $(APPVEYOR_PROJECT_NAME)-$(APPVEYOR_REPO_BRANCH)-$(PLATFORM) + +before_deploy: + - ps: | + if ($env:APPVEYOR_REPO_TAG -eq "true") { + $env:RELEASE_DRAFT = $false; + $env:RELEASE_PRE = $env:TRAKT_VERSION_PRE_TYPE -ne $null; + } else { + $env:TRAKT_VERSION_NAME = "$env:APPVEYOR_REPO_BRANCH-branch"; + $env:RELEASE_PRE = $false; + $env:RELEASE_DRAFT = $true; + $env:TRAKT_VERSION_DESCRIPTION = "Draft of release for branch $env:APPVEYOR_REPO_BRANCH. $env:TRAKT_VERSION_DESCRIPTION"; + } + - ps: 'echo "RELEASE NAME: $env:TRAKT_VERSION_NAME"' + - ps: 'echo "RELEASE DESCRIPTION: $env:TRAKT_VERSION_DESCRIPTION"' + - ps: 'echo "IS PRE RELEASE ? $env:RELEASE_PRE"' + - ps: 'echo "IS RELEASE DRAFT ? $env:RELEASE_DRAFT"' + +deploy: + - tag: $(APPVEYOR_REPO_TAG_NAME) + release: $(TRAKT_VERSION_NAME) + description: $(TRAKT_VERSION_DESCRIPTION) + provider: GitHub + auth_token: $(GITHUB_TOKEN) + artifact: $(APPVEYOR_PROJECT_NAME)-$(APPVEYOR_REPO_BRANCH)-$(PLATFORM) + draft: $(RELEASE_DRAFT) + prerelease: $(RELEASE_PRE) + on: + should_deploy: yes + appveyor_repo_tag: true + +on_success: + # Check if we need to update the 'latest' rolling release + - ps : | + if ($env:APPVEYOR_REPO_TAG -ne 'true' -and $env:APPVEYOR_REPO_BRANCH -eq 'master' -and $env:APPVEYOR_PULL_REQUEST_NUMBER -eq $null -and $env:APPVEYOR_API_TOKEN -ne $null) { + $env:UPDATE_LATEST = "true" + } else { + $env:UPDATE_LATEST = "false" + } + # To update the 'latest' rolling release if we are on the right branch + - if [%UPDATE_LATEST%]==[true] ( + pipenv install scikit-ci-addons & + pipenv run ci_addons publish_github_release --prerelease-packages "dist\\%TRAKT_HELPER_BIN%" --prerelease-packages-clear-pattern "TraktForVLC_*_windows_%PLATFORM%.exe" --prerelease-packages-keep-pattern "%TRAKT_HELPER_BIN%" --prerelease-sha "%APPVEYOR_REPO_BRANCH%" --re-upload "%APPVEYOR_REPO_NAME%" + ) + # The cancel-queued-build.ps1 script does not take care of deleting the jobs, + # and it will become a lot of unused jobs very fast. To keep that clean, we + # just run that request at the end to clean it + - ps: | + if ($env:UPDATE_LATEST -eq "true") { + (Invoke-RestMethod https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=10).builds | Where-Object {$_.isTag -and $_.tag -match '^latest(-tmp)?$'} | %{ + DeleteBuild $_.status $_.version $_.buildId + } + } diff --git a/helper/__init__.py b/helper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/helper/commands/__init__.py b/helper/commands/__init__.py new file mode 100644 index 0000000..20d16d2 --- /dev/null +++ b/helper/commands/__init__.py @@ -0,0 +1,32 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + + +import date # noqa: F401 +import extraids # noqa: F401 +import init_trakt_auth # noqa: F401 +import install # noqa: F401 +import requests # noqa: F401 +import resolve # noqa: F401 +import runvlc # noqa: F401 +import service # noqa: F401 +import uninstall # noqa: F401 +import update # noqa: F401 diff --git a/helper/commands/date.py b/helper/commands/date.py new file mode 100644 index 0000000..8178afa --- /dev/null +++ b/helper/commands/date.py @@ -0,0 +1,107 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import datetime +import json +import logging +import pytz + +from helper.utils import ( + Command, +) + +LOGGER = logging.getLogger(__name__) + + +########################################################################## +# The DATE command to perform operations on dates +class CommandDate(Command): + command = 'date' + description = 'To perform operations on dates' + + def add_arguments(self, parser): + parser.add_argument( + '--format', + action='append', + default=[], + help='The format of the date to output', + ) + parser.add_argument( + '--timezone', + help='Which timezone to use for the destination date', + ) + parser.add_argument( + '--from', + dest='from_date', + help='From which time to print the time', + ) + parser.add_argument( + '--from-timezone', + help='Which timezone to use for the from date, if different than ' + 'the destination date', + ) + parser.add_argument( + '--from-format', + default='%s.%f', + help='Format of the date passed in the from argument', + ) + + def run(self, format, timezone, from_date, from_timezone, from_format): + if not format: + format = ['%Y-%m-%dT%H:%M:%S.%fZ', ] + if from_date: + if from_format in ['%s', '%s.%f']: + from_date = float(from_date) + from_dt = datetime.datetime.fromtimestamp(from_date) + else: + from_dt = datetime.datetime.strptime(from_date, from_format) + if from_timezone: + from_tz = pytz.timezone(from_timezone) + from_dt = from_tz.localize(from_dt) + else: + from_dt = pytz.utc.localize(from_dt) + else: + from_dt = pytz.utc.localize(datetime.datetime.utcnow()) + + if timezone: + to_tz = pytz.timezone(timezone) + else: + to_tz = pytz.utc + + to_dt = from_dt.astimezone(to_tz) + + date = [ + { + 'format': f, + 'date': to_dt.strftime(f), + 'timezone': to_tz.zone, + } + for f in format + ] + if len(date) == 1: + date = date[0] + print(json.dumps(date, sort_keys=True, + indent=4, separators=(',', ': '), + ensure_ascii=False)) diff --git a/helper/commands/extraids.py b/helper/commands/extraids.py new file mode 100644 index 0000000..50f29e7 --- /dev/null +++ b/helper/commands/extraids.py @@ -0,0 +1,284 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import argparse +import json +import logging +import tmdbsimple +import tvdb_api + +from helper.utils import ( + Command, +) + +LOGGER = logging.getLogger(__name__) + +# Set the TMDB API KEY - If you fork this project, please change that +# API KEY to your own! +tmdbsimple.API_KEY = 'ad2d828c5ec2d46c06e1c38e181c125b' + + +############################################################################## +# To resolve an episode ids +def resolve_episode_ids(series, season, episode, year=None): + # To store the IDs found + ids = {} + + # Initialize a TMDB search object + tmdb_search = tmdbsimple.Search() + + ################################################################## + # TVDB + tvdb = tvdb_api.Tvdb( + cache=False, + language='en', + ) + + tvdb_series = None + try: + # Try getting the series directly, but check the year if available + # as sometimes series have the same name but are from different years + tvdb_series = tvdb[series] + if year is not None and tvdb_series['firstAired'] and \ + year != int(tvdb_series['firstAired'].split('-')[0]): + # It is not the expected year, we thus need to perform a search + tvdb_series = None + tvdb_search = tvdb.search(series) + for s in tvdb_search: + if s['seriesName'].startswith(series) and \ + int(s['firstAired'].split('-')[0]) == year: + tvdb_series = tvdb[s['seriesName']] + break + + tvdb_season = tvdb_series[season] + tvdb_episode = tvdb_season[episode] + ids['tvdb'] = tvdb_episode['id'] + except Exception as e: + LOGGER.debug(e) + LOGGER.warning('Unable to find series {}, season {}, ' + 'episode {} on TheTVDB'.format( + series, season, episode)) + + ################################################################## + # TMDB + params = {'query': series} + if year is not None: + params['first_air_date_year'] = year + + try: + tmdb_search.tv(**params) + except Exception as e: + LOGGER.debug(e) + tmdb_search.results = [] + + for s in tmdb_search.results: + if s['name'] != series and \ + (tvdb_series is None or + (s['name'] != tvdb_series['seriesName'] and + s['name'] not in tvdb_series['aliases'])): + continue + + # Try to get the episode information + tmdb_episode = tmdbsimple.TV_Episodes(s['id'], season, episode) + try: + tmdb_external_ids = tmdb_episode.external_ids() + except Exception as e: + continue + + # If we have the tvdb information, check that we got the right + # id... else, it is probably not the episode we are looking + # for! + if 'tvdb' in ids and \ + tmdb_external_ids.get('tvdb_id') is not None and \ + ids['tvdb'] != tmdb_external_ids['tvdb_id']: + continue + + ids['tmdb'] = tmdb_external_ids['id'] + break + + return ids + + +############################################################################## +# To resolve a movie ids +def resolve_movie_ids(movie, year=None): + # To store the IDs found + ids = {} + + # Initialize a TMDB search object + search = tmdbsimple.Search() + + ################################################################## + # TMDB + params = {'query': movie} + if year is not None: + params['year'] = year + + try: + search.movie(**params) + except Exception as e: + LOGGER.debug(e) + search.results = [] + + for s in search.results: + if s['title'] != movie.decode('utf-8'): + continue + + ids['tmdb'] = s['id'] + break + + return ids + + +############################################################################## +# To represent a media object +class Media(object): + series = None + season = None + episode = None + movie = None + year = None + + +############################################################################## +# Allow to represent an episode in the "series season episode [year]" format +class ActionEpisode(argparse.Action): + def __init__(self, option_strings, dest, default=None, + required=False, help=None): + super(ActionEpisode, self).__init__( + option_strings, dest, nargs='+', const=None, default=default, + required=required, help=help) + + def __call__(self, parser, namespace, values, option_strings=None): + if len(values) < 3 or len(values) > 4: + parser.error('argument {}: format is SERIES_NAME SEASON_NUMBER ' + 'EPISODE_NUMBER [YEAR]') + return + + for i, v in enumerate(values[1:]): + try: + values[i + 1] = int(v) + except ValueError: + parser.error('argument {}: invalid int value: \'{}\''.format( + option_strings, v)) + return + + media = Media() + media.series = values[0] + media.season = values[1] + media.episode = values[2] + if len(values) > 3: + media.year = values[3] + + current = getattr(namespace, self.dest) + if current is None: + current = [] + + setattr(namespace, self.dest, current + [media, ]) + + +############################################################################## +# Allow to represent a movie in the "movie [year]" format +class ActionMovie(argparse.Action): + def __init__(self, option_strings, dest, default=None, + required=False, help=None): + super(ActionMovie, self).__init__( + option_strings, dest, nargs='+', const=None, default=default, + required=required, help=help) + + def __call__(self, parser, namespace, values, option_strings=None): + if len(values) < 1 or len(values) > 2: + parser.error('argument {}: format is MOVIE_NAME [YEAR]') + return + + for i, v in enumerate(values[1:]): + try: + values[i + 1] = int(v) + except ValueError: + parser.error('argument {}: invalid int value: \'{}\''.format( + option_strings, v)) + return + + media = Media() + media.movie = values[0] + if len(values) > 1: + media.year = values[1] + + current = getattr(namespace, self.dest) + if current is None: + current = [] + + setattr(namespace, self.dest, current + [media, ]) + + +########################################################################## +# The EXTRAIDS command to find extra ids for a given series/movie +class CommandExtraIDs(Command): + command = 'extraids' + description = ('Find extra IDs for a given movie/episode in order to ' + 'find it on Trakt.tv') + + def add_arguments(self, parser): + parser.add_argument( + '--episode', + help='The episode to search extra ids for; must be called with ' + '--episode SERIES_NAME SEASON_NUMBER EPISODE_NUMBER [YEAR]', + action=ActionEpisode, + dest='episodes', + ) + parser.add_argument( + '--movie', + help='The movie to search extra ids for; must be called with ' + '--movie MOVIE_NAME [YEAR]', + action=ActionMovie, + dest='movies', + ) + + def run(self, episodes, movies): + ids = {} + + if episodes is None: + episodes = [] + if movies is None: + movies = [] + + for e in episodes: + ep_ids = resolve_episode_ids(e.series, e.season, + e.episode, e.year) + + ids.setdefault( + 'episode', {}).setdefault( + e.series, {}).setdefault( + e.season, {})[e.episode] = ep_ids + + for m in movies: + mov_ids = resolve_movie_ids(m.movie, m.year) + + ids.setdefault( + 'movie', {})[m.movie] = mov_ids + + print(json.dumps(ids, sort_keys=True, + indent=4, separators=(',', ': '), + ensure_ascii=False)) diff --git a/helper/commands/init_trakt_auth.py b/helper/commands/init_trakt_auth.py new file mode 100644 index 0000000..6b1d7f1 --- /dev/null +++ b/helper/commands/init_trakt_auth.py @@ -0,0 +1,144 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import logging +import platform +import subprocess + +from helper.utils import ( + Command, + get_resource_path, + get_vlc, + run_as_user, +) + +LOGGER = logging.getLogger(__name__) + + +########################################################################## +# The INIT_TRAKT_AUTH command to initialize Trakt.tv authentication +class CommandInitTraktAuth(Command): + command = 'init_trakt_auth' + description = 'Initialize TraktForVLC authentication with trakt.tv' + + def add_arguments(self, parser): + parser.add_argument( + '--vlc', + dest='vlc_bin', + help='To specify manually where the VLC executable is', + ) + + def run(self, vlc_bin): + # Try to find the VLC executable if it has not been passed as + # parameter + if not vlc_bin: + LOGGER.info('Searching for VLC binary...') + vlc_bin = get_vlc() + # If we still did not find it, cancel the installation as we will + # not be able to complete it + if not vlc_bin: + raise RuntimeError( + 'VLC executable not found: use the --vlc parameter ' + 'to specify VLC location') + + # Preparing the command + command = [ + vlc_bin, + '--lua-config', + 'trakt={init_auth=1}', + ] + if platform.system() == 'Windows': + command.extend([ + '--osd', + '--repeat', + get_resource_path('vlc-logo-1s.mp4'), + ]) + print('A VLC window will open, please follow the instructions') + print('that will appear in that window: go to the provided link') + print('and enter the given code, then wait patiently as') + print('TraktForVLC is configured to get access to your account.') + else: + command.extend([ + '--intf', 'cli', + ]) + + LOGGER.debug('Running command: {}'.format( + subprocess.list2cmdline(command))) + + run = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **run_as_user() + ) + try: + success = None + grabbing = None + for line in iter(run.stdout.readline, b''): + line = line.strip() + LOGGER.debug(line) + + # If we read a line that encloses messages for the device code + # process, process it + if line == '############################': + if grabbing is None: + grabbing = [] + else: + if grabbing[0] == \ + 'TraktForVLC is not setup with Trakt.tv yet!': + grabbing = grabbing[2:] + elif grabbing[0] == \ + ('TraktForVLC setup failed; Restart VLC ' + 'to try again'): + success = False + elif grabbing[0] == \ + 'TraktForVLC is now setup with Trakt.tv!': + success = True + + # Skip the print on Windows, as we are using the OSD + # to show the code to enter + if platform.system() != 'Windows': + print('\n'.join(grabbing)) + + grabbing = None + elif grabbing is not None: + # If we are currently grabbing lines, grab this one + grabbing.append(line) + except KeyboardInterrupt: + run.kill() + finally: + run.stdout.close() + + if platform.system() == 'Windows': + if success: + print('Yey! TraktForVLC is now configured with trakt.tv! :)') + elif success is False: + print('Meh! TraktForVLC setup with trakt.tv failed! :(') + else: + print('Well, this is embarrassing... something happened, and ' + 'it does not seem to have worked! :|') + + return run.wait() diff --git a/helper/commands/install.py b/helper/commands/install.py new file mode 100644 index 0000000..054c6c3 --- /dev/null +++ b/helper/commands/install.py @@ -0,0 +1,417 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import io +import json +import logging +import os +import platform +import shutil +import stat +import subprocess +import sys + +from helper.commands.init_trakt_auth import ( + CommandInitTraktAuth, +) +from helper.commands.service import ( + run_windows_service, +) +from helper.parser import ( + ActionYesNo, +) +from helper.utils import ( + ask_yes_no, + Command, + get_os_config, + get_resource_path, + get_vlc, + redirectstd, + run_as_user, +) +from helper.version import ( + __version__, +) + +LOGGER = logging.getLogger(__name__) + + +########################################################################## +# Command class that allows to set the common arguments for the INSTALL, +# UPDATE and UNINSTALL commands +class CommandInstallUpdateDelete(Command): + def add_arguments(self, parser): + parser.add_argument( + '--system', + help='Use system directories instead of per-user', + action='store_true', + ) + parser.add_argument( + '--service', + help='Install TraktForVLC as a service (only available - and ' + 'mandatory - on Windows currently)', + action=ActionYesNo, + default=(platform.system() == 'Windows'), + ) + parser.add_argument( + '--service-host', + help='Host to be used by the service to bind to ' + '[default: 127.0.0.1]', + default='localhost', + ) + parser.add_argument( + '--service-port', + type=int, + help='Port to be used by the service to bind to ' + '[default: 1984]', + default=1984, + ) + parser.add_argument( + '-y', '--yes', + help='Do not prompt, approve all changes automatically.', + action='store_true', + ) + parser.add_argument( + '-n', '--dry-run', + help='Only perform a dry-run for the command (nothing actually ' + 'executed)', + action='store_true', + ) + parser.add_argument( + '--vlc-config-directory', + dest='vlc_config', + help='To specify manually where the VLC configuration ' + 'directory is', + ) + parser.add_argument( + '--vlc-lua-directory', + dest='vlc_lua', + help='To specify manually where the VLC LUA directory is', + ) + parser.add_argument( + '--vlc', + dest='vlc_bin', + help='To specify manually where the VLC executable is', + ) + + +########################################################################## +# The INSTALL command to install TraktForVLC +class CommandInstall(CommandInstallUpdateDelete): + command = 'install' + description = 'To install TraktForVLC' + + def add_arguments(self, parser): + super(CommandInstall, self).add_arguments(parser) + + parser.add_argument( + '--init-trakt-auth', '--force-init-trakt-auth', + help='To initialize the authentication with Trakt.tv during the ' + 'installation process; By default, the authentication ' + 'process will be started only if no configuration file ' + 'exists, but the --force-init-trakt-auth option allows to ' + 'execute it in any case.', + default=True, + action=ActionYesNo, + ) + + def run(self, dry_run, yes, system, service, service_host, service_port, + vlc_bin, vlc_config, vlc_lua, init_trakt_auth): + if service and platform.system() != 'Windows': + LOGGER.error('The service mode is not supported yet for {}'.format( + platform.system())) + + os_config = get_os_config(system or service, vlc_config, vlc_lua) + + # Try to find the VLC executable if it has not been passed as + # parameter + if not vlc_bin: + LOGGER.info('Searching for VLC binary...') + vlc_bin = get_vlc() + # If we still did not find it, cancel the installation as we will + # not be able to complete it + if not vlc_bin: + raise RuntimeError( + 'VLC executable not found: use the --vlc parameter ' + 'to specify VLC location') + else: + LOGGER.info('VLC binary: {}'.format(vlc_bin)) + + # Check that the trakt.luac file can be found + trakt_lua = get_resource_path('trakt.luac') + # If it cannot, try to find the trakt.lua file instead + if not os.path.isfile(trakt_lua): + trakt_lua = get_resource_path('trakt.lua') + # If still not found, cancel the installation + if not os.path.isfile(trakt_lua): + raise RuntimeError( + 'trakt.luac/trakt.lua file not found, unable to install') + + # Compute the path to the directories we need to use after + config = os_config['config'] + lua = os_config['lua'] + lua_intf = os.path.join(lua, 'intf') + + # Compute the name of the helper + if getattr(sys, 'frozen', False): + trakt_helper = sys.executable + trakt_helper_dest = 'trakt_helper' + if platform.system() == 'Windows': + trakt_helper_dest = '{}.exe'.format(trakt_helper_dest) + else: + trakt_helper = os.path.realpath(__file__) + trakt_helper_dest = os.path.basename(__file__) + trakt_helper_path = os.path.join(lua, trakt_helper_dest) + + # Show install information to the user, and query for approbation + # if --yes was not used + print('\n'.join([ + 'TraktForVLC will be installed for the following configuration:', + ' - OS: {}'.format(platform.system()), + ' - VLC: {}'.format(vlc_bin), + ' - VLC configuration: {}'.format(config), + ' - VLC Lua: {}'.format(lua), + ' - VLC Lua interface: {}'.format(lua_intf), + ' - Service ? {}'.format('{}:{}'.format( + service_host, service_port) if service else 'No'), + ])) + if os.path.isfile(trakt_helper_path): + print('TraktForVLC is currently installed, replacing the current ' + 'installed version will call \'uninstall\' using the ' + 'old binary, and \'install\' with the new one.') + if not yes: + yes_no = ask_yes_no('Proceed with installation ?') + if not yes_no: + print('Installation aborted by {}.'.format( + 'signal' if yes_no is None else 'user')) + return + + if os.path.isfile(trakt_helper_path): + LOGGER.info('Uninstalling currently installed TraktForVLC') + command = [ + trakt_helper_path, + '--logformat', 'UNINSTALL::%(levelname)s::%(message)s', + '--loglevel', logging.getLevelName(LOGGER.getEffectiveLevel()), + 'uninstall', + '--vlc', vlc_bin, + '--vlc-lua-directory', lua, + '--vlc-config-directory', config, + '--yes', + ] + if system: + command.append('--system') + if service: + command.append('--service') + if dry_run: + command.append('--dry-run') + + LOGGER.debug('Running command: {}'.format( + subprocess.list2cmdline(command))) + uninstall = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1 + ) + for line in iter(uninstall.stderr.readline, b''): + LOGGER.info(line.strip()) + uninstall.stderr.close() + + if uninstall.wait() != 0: + LOGGER.error('Unable to uninstall TraktForVLC') + return -1 + + # Create all needed directories + needed_dirs = [lua_intf] + if service: + needed_dirs.append(config) + + for d in needed_dirs: + if not os.path.isdir(d): + LOGGER.info('Creating directory (and parents): {}'.format(d)) + if not dry_run: + os.makedirs(d) + + # Copy the trakt helper executable in the Lua directory of VLC + LOGGER.info('Copying helper ({}) to {}'.format( + trakt_helper_dest, lua)) + if not dry_run: + shutil.copy2(trakt_helper, trakt_helper_path) + if system and platform.system() != 'Windows': + LOGGER.info('Setting permissions of {} to 755'.format( + trakt_helper_path)) + if not dry_run: + os.chmod(trakt_helper_path, + stat.S_IRWXU | + stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) + + # Then copy the trakt.lua file in the Lua interface directory of VLC + LOGGER.info('Copying {} to {}'.format( + os.path.basename(trakt_lua), lua_intf)) + trakt_lua_path = os.path.join(lua_intf, os.path.basename(trakt_lua)) + if not dry_run: + shutil.copy2(trakt_lua, lua_intf) + if system and platform.system() != 'Windows': + LOGGER.info('Setting permissions of {} to 644'.format( + trakt_lua_path)) + if not dry_run: + os.chmod(trakt_lua_path, + stat.S_IRUSR | stat.S_IWUSR | + stat.S_IRGRP | + stat.S_IROTH) + + # If we are configuring TraktForVLC as a service, we need to + # install/update the service + if service: + LOGGER.info('Setting up TraktForVLC service') + if not dry_run: + output = io.BytesIO() + with redirectstd(output): + run_windows_service(action='install', + host=service_host, + port=service_port, + exeName=trakt_helper_path) + + service_install_lines = output.getvalue().splitlines() + if service_install_lines[-1] in [ + 'Service installed', 'Service updated']: + LOGGER.info(service_install_lines[-1]) + else: + for l in service_install_lines: + LOGGER.error(l) + return -1 + + LOGGER.info('Starting up TraktForVLC service') + if not dry_run: + output = io.BytesIO() + with redirectstd(output): + run_windows_service(action='restart') + + service_restart_lines = output.getvalue().splitlines() + if service_restart_lines[-1] == \ + 'Restarting service TraktForVLC': + LOGGER.info('Service ready') + else: + for l in service_restart_lines: + LOGGER.error(l) + return -1 + + # If we are configuring TraktForVLC as a service, we need to + # update/create the TraktForVLC configuration file so it will know + # how to reach the service; if it is not the case, and there is a + # TraktForVLC configuration file, insure it is set up to use + # TraktForVLC as a standalone. + trakt_config = os.path.join(config, 'trakt_config.json') + data = {} + data_updated = False + config_file_exists = False + if os.path.isfile(trakt_config): + LOGGER.info('Configuration file exists, reading current values') + with open(trakt_config, 'r') as f: + data = json.load(f) + config_file_exists = True + + if service: + data_updated = True + LOGGER.info('Configuring TraktForVLC to use the service') + + if 'helper' not in data: + data['helper'] = {} + if 'service' not in data['helper']: + data['helper']['service'] = {} + + data['helper']['mode'] = 'service' + data['helper']['service']['host'] = service_host + data['helper']['service']['port'] = service_port + elif data.get('helper', {}).get('mode', 'standalone') != 'standalone': + LOGGER.info( + 'Configuring TraktForVLC helper to be used as standalone') + data['helper']['mode'] = 'standalone' + data_updated = True + + # In the future, this might be useful to actually reorganize + # configuration if there has been any change + if data and data.get('config_version') != __version__: + LOGGER.info('Update configuration version') + data['config_version'] = __version__ + data_updated = True + + if data_updated and not dry_run: + with open(trakt_config, 'w') as f: + json.dump(data, f, sort_keys=True, + indent=4, separators=(',', ': ')) + + # We then need to start VLC with the trakt interface enabled, and + # pass the autostart=enable parameter so VLC will be setup + LOGGER.info('Setting up VLC to automatically use trakt\'s interface') + configured = False + if not dry_run: + command = [ + vlc_bin, + '-I', 'luaintf', + '--lua-intf', 'trakt', + '--lua-config', 'trakt={autostart="enable"}', + ] + LOGGER.debug('Running command: {}'.format( + subprocess.list2cmdline(command))) + enable = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **run_as_user() + ) + output = [] + for line in iter(enable.stdout.readline, b''): + line = line.strip() + output.append(line) + LOGGER.debug(line) + if line.endswith('[trakt] lua interface: VLC is configured to ' + 'automatically use TraktForVLC'): + configured = True + enable.stdout.close() + + if enable.wait() != 0: + LOGGER.error('Unable to enable VLC ' + 'lua interface:\n{}'.format('\n'.join(output))) + return -1 + else: + configured = True + + if configured: + LOGGER.info('VLC configured') + else: + LOGGER.error('Error while configuring VLC') + return -1 + + LOGGER.info('TraktForVLC v{} is now installed. :D'.format(__version__)) + + # Initialize the configuration if requested + if (init_trakt_auth is True and not config_file_exists) or \ + init_trakt_auth == (True, 'force'): + LOGGER.info('Initializing authentication with Trakt.tv') + init_trakt = CommandInitTraktAuth() + init_trakt.run(vlc_bin=vlc_bin) diff --git a/helper/commands/requests.py b/helper/commands/requests.py new file mode 100644 index 0000000..4723998 --- /dev/null +++ b/helper/commands/requests.py @@ -0,0 +1,129 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import json +import logging +import requests + +from helper.utils import ( + Command, +) + +LOGGER = logging.getLogger(__name__) + + +########################################################################## +# The REQUESTS command to perform HTTP/HTTPS requests +class CommandRequests(Command): + command = 'requests' + description = 'To perform HTTP/HTTPS requests' + + def add_arguments(self, parser): + parser.add_argument( + 'method', + help='The method to use for the request', + type=str.upper, + choices=[ + 'GET', + 'POST', + ], + ) + parser.add_argument( + 'url', + help='The URL to perform the request to', + ) + parser.add_argument( + '--headers', + help='The headers to set for the request', + ) + parser.add_argument( + '--data', + help='The data to be sent with the request', + ) + + def run(self, method, url, headers, data): + if headers is None: + headers = {} + else: + try: + headers = { + k: str(v) + for k, v in json.loads(headers).items() + } + except Exception as e: + raise RuntimeError( + 'Headers argument could not be parsed as JSON: {}'.format( + e.message)) + + ###################################################################### + # Prepare the parameters + params = { + 'url': url, + 'headers': headers, + } + if data is not None: + try: + data = json.loads(data) + except Exception as e: + raise RuntimeError( + 'Data argument could not be parsed as JSON: {}'.format( + e.message)) + params['json'] = data + + ###################################################################### + # Find the request method to use + req_func = getattr(requests, method.lower(), None) + if not req_func: + raise RuntimeError('Function to perform HTTP/HTTPS request for ' + 'method {} not found'.format(method)) + + ###################################################################### + # Perform the request + resp = req_func(**params) + + ###################################################################### + # Prepare the result dict + result = { + 'status_code': resp.status_code, + 'reason': resp.reason, + 'url': resp.url, + 'headers': dict(resp.headers), + 'body': resp.text, + 'request': { + 'url': resp.request.url, + 'method': resp.request.method, + 'headers': dict(resp.request.headers), + 'body': resp.request.body or '', + }, + } + try: + result['json'] = resp.json() + except Exception as e: + pass + + ###################################################################### + # Print the JSON dump of the result + print(json.dumps(result, sort_keys=True, + indent=4, separators=(',', ': '))) diff --git a/helper/commands/resolve.py b/helper/commands/resolve.py new file mode 100644 index 0000000..ca148aa --- /dev/null +++ b/helper/commands/resolve.py @@ -0,0 +1,890 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import fuzzywuzzy.fuzz +import imdbpie +import json +import logging +import math +import re +import sys +import tvdb_api + +from helper.commands.extraids import ( + resolve_episode_ids, + resolve_movie_ids, +) +from helper.utils import ( + Command, +) +from helper.version import ( + __version__, +) + +try: + import xmlrpclib as xmlrpc +except ImportError: + import xmlrpc.client as xmlrpc + +LOGGER = logging.getLogger(__name__) + + +############################################################################## +# Parse a filename to the series/movie +def parse_filename(filename): + if type(filename) == bytes: + filename = filename.decode() + + def cleanRegexedName(name): + name = re.sub("[.](?!.(?:[.]|$))", " ", name) + name = re.sub("(?<=[^. ]{2})[.]", " ", name) + name = name.replace("_", " ") + name = name.strip("- ") + return name + + found = {} + + # Patterns to parse input series filenames with + # These patterns come directly from the tvnamer project available + # on https://github.com/dbr/tvnamer + series_patterns = [ + # [group] Show - 01-02 [crc] + '''^\[(?P.+?)\][ ]? + (?P.*?)[ ]?[-_][ ]? + (?P\d+) + ([-_]\d+)* + [-_](?P\d+) + (?= + .* + \[(?P.+?)\] + )? + [^\/]*$''', + + # [group] Show - 01 [crc] + '''^\[(?P.+?)\][ ]? + (?P.*) + [ ]?[-_][ ]? + (?P\d+) + (?= + .* + \[(?P.+?)\] + )? + [^\/]*$''', + + # foo s01e23 s01e24 s01e25 * + '''^((?P.+?)[ \._\-])? + [Ss](?P[0-9]+) + [\.\- ]? + [Ee](?P[0-9]+) + ([\.\- ]+ + [Ss](?P=seasonnumber) + [\.\- ]? + [Ee][0-9]+)* + ([\.\- ]+ + [Ss](?P=seasonnumber) + [\.\- ]? + [Ee](?P[0-9]+)) + [^\/]*$''', + + # foo.s01e23e24* + '''^((?P.+?)[ \._\-])? + [Ss](?P[0-9]+) + [\.\- ]? + [Ee](?P[0-9]+) + ([\.\- ]? + [Ee][0-9]+)* + [\.\- ]?[Ee](?P[0-9]+) + [^\/]*$''', + + # foo.1x23 1x24 1x25 + '''^((?P.+?)[ \._\-])? + (?P[0-9]+) + [xX](?P[0-9]+) + ([ \._\-]+ + (?P=seasonnumber) + [xX][0-9]+)* + ([ \._\-]+ + (?P=seasonnumber) + [xX](?P[0-9]+)) + [^\/]*$''', + + # foo.1x23x24* + '''^((?P.+?)[ \._\-])? + (?P[0-9]+) + [xX](?P[0-9]+) + ([xX][0-9]+)* + [xX](?P[0-9]+) + [^\/]*$''', + + # foo.s01e23-24* + '''^((?P.+?)[ \._\-])? + [Ss](?P[0-9]+) + [\.\- ]? + [Ee](?P[0-9]+) + ( + [\-] + [Ee]?[0-9]+ + )* + [\-] + [Ee]?(?P[0-9]+) + [\.\- ] + [^\/]*$''', + + # foo.1x23-24* + '''^((?P.+?)[ \._\-])? + (?P[0-9]+) + [xX](?P[0-9]+) + ( + [\-+][0-9]+ + )* + [\-+] + (?P[0-9]+) + ([\.\-+ ].* + | + $)''', + + # foo.[1x09-11]* + '''^(?P.+?)[ \._\-] + \[ + ?(?P[0-9]+) + [xX] + (?P[0-9]+) + ([\-+] [0-9]+)* + [\-+] + (?P[0-9]+) + \] + [^\\/]*$''', + + # foo - [012] + '''^((?P.+?)[ \._\-])? + \[ + (?P[0-9]+) + \] + [^\\/]*$''', + # foo.s0101, foo.0201 + '''^(?P.+?)[ \._\-] + [Ss](?P[0-9]{2}) + [\.\- ]? + (?P[0-9]{2}) + [^0-9]*$''', + + # foo.1x09* + '''^((?P.+?)[ \._\-])? + \[? + (?P[0-9]+) + [xX] + (?P[0-9]+) + \]? + [^\\/]*$''', + + # foo.s01.e01, foo.s01_e01, "foo.s01 - e01" + '''^((?P.+?)[ \._\-])? + \[? + [Ss](?P[0-9]+)[ ]?[\._\- ]?[ ]? + [Ee]?(?P[0-9]+) + \]? + [^\\/]*$''', + + # foo.2010.01.02.etc + ''' + ^((?P.+?)[ \._\-])? + (?P\d{4}) + [ \._\-] + (?P\d{2}) + [ \._\-] + (?P\d{2}) + [^\/]*$''', + + # foo - [01.09] + '''^((?P.+?)) + [ \._\-]? + \[ + (?P[0-9]+?) + [.] + (?P[0-9]+?) + \] + [ \._\-]? + [^\\/]*$''', + + # Foo - S2 E 02 - etc + '''^(?P.+?)[ ]?[ \._\-][ ]? + [Ss](?P[0-9]+)[\.\- ]? + [Ee]?[ ]?(?P[0-9]+) + [^\\/]*$''', + + # Show - Episode 9999 [S 12 - Ep 131] - etc + '''(?P.+) + [ ]-[ ] + [Ee]pisode[ ]\d+ + [ ] + \[ + [sS][ ]?(?P\d+) + ([ ]|[ ]-[ ]|-) + ([eE]|[eE]p)[ ]?(?P\d+) + \] + .*$ + ''', + + # show name 2 of 6 - blah + '''^(?P.+?) + [ \._\-] + (?P[0-9]+) + of + [ \._\-]? + \d+ + ([\._ -]|$|[^\\/]*$) + ''', + + # Show.Name.Part.1.and.Part.2 + '''^(?i) + (?P.+?) + [ \._\-] + (?:part|pt)?[\._ -] + (?P[0-9]+) + (?: + [ \._-](?:and|&|to) + [ \._-](?:part|pt)? + [ \._-](?:[0-9]+))* + [ \._-](?:and|&|to) + [ \._-]?(?:part|pt)? + [ \._-](?P[0-9]+) + [\._ -][^\\/]*$ + ''', + + # Show.Name.Part1 + '''^(?P.+?) + [ \\._\\-] + [Pp]art[ ](?P[0-9]+) + [\\._ -][^\\/]*$ + ''', + + # show name Season 01 Episode 20 + '''^(?P.+?)[ ]? + [Ss]eason[ ]?(?P[0-9]+)[ ]? + [Ee]pisode[ ]?(?P[0-9]+) + [^\\/]*$''', + + # foo.103* + '''^(?P.+)[ \._\-] + (?P[0-9]{1}) + (?P[0-9]{2}) + [\._ -][^\\/]*$''', + + # foo.0103* + '''^(?P.+)[ \._\-] + (?P[0-9]{2}) + (?P[0-9]{2,3}) + [\._ -][^\\/]*$''', + + # show.name.e123.abc + '''^(?P.+?) + [ \._\-] + [Ee](?P[0-9]+) + [\._ -][^\\/]*$ + ''', + ] + + # Search if we find a series + for pattern in series_patterns: + m = re.match(pattern, filename, re.VERBOSE | re.IGNORECASE) + if not m: + continue + + groupnames = m.groupdict().keys() + series = { + 'type': 'episode', + 'show': None, + 'season': None, + 'episodes': None, + } + + # Show name + series['show'] = cleanRegexedName(m.group('seriesname')) + + # Season + series['season'] = int(m.group('seasonnumber')) + + # Episodes + if 'episodenumberstart' in groupnames: + if m.group('episodenumberend'): + start = int(m.group('episodenumberstart')) + end = int(m.group('episodenumberend')) + if start > end: + start, end = end, start + series['episodes'] = list(range(start, end + 1)) + else: + series['episodes'] = [int(m.group('episodenumberstart')), ] + elif 'episodenumber' in groupnames: + series['episodes'] = [int(m.group('episodenumber')), ] + + found['episode'] = series + break + + # The patterns that will be used to search for a movie + movies_patterns = [ + '''^(\(.*?\)|\[.*?\])?( - )?[ ]*? + (?P.*?) + (dvdrip|xvid| cd[0-9]|dvdscr|brrip|divx| + [\{\(\[]?(?P[0-9]{4})) + .*$ + ''', + + '''^(\(.*?\)|\[.*?\])?( - )?[ ]*? + (?P.+?)[ ]*? + (?:[[(]?(?P[0-9]{4})[])]?.*)? + (?:\.[a-zA-Z0-9]{2,4})?$ + ''', + ] + + # Search if we find a series + for pattern in movies_patterns: + m = re.match(pattern, filename, re.VERBOSE | re.IGNORECASE) + if not m: + continue + + groupnames = m.groupdict().keys() + movie = { + 'type': 'movie', + 'title': None, + 'year': None, + } + + # Movie title + movie['title'] = cleanRegexedName(m.group('moviename')) + + # Year + if 'year' in groupnames and m.group('year'): + movie['year'] = m.group('year') + + found['movie'] = movie + break + + if found: + return found + + # Not found + return False + + +########################################################################## +# Internal function to convert unicode strings to byte strings +def tobyte(input): + if isinstance(input, dict): + return {tobyte(key): tobyte(value) + for key, value in input.iteritems()} + elif isinstance(input, list): + return [tobyte(element) for element in input] + elif isinstance(input, unicode): + return input.encode('utf-8') + else: + return input + + +########################################################################## +# Class to represent resolution exceptions +class ResolveException(Exception): + pass + + +########################################################################## +# Class to get OpenSubtitles XML-RPC API proxy +class OpenSubtitlesAPI(object): + _connected = False + + @classmethod + def _connect(cls): + if cls._connected: + return + + # Initialize the connection to opensubtitles + cls._proxy = xmlrpc.ServerProxy( + 'https://api.opensubtitles.org/xml-rpc') + cls._login = cls._proxy.LogIn( + '', '', 'en', 'TraktForVLC v{}'.format(__version__)) + cls._connected = True + + @classmethod + def check_hash(cls, *args, **kwargs): + cls._connect() + return cls._proxy.CheckMovieHash2( + cls._login['token'], *args, **kwargs) + + @classmethod + def insert_hash(cls, *args, **kwargs): + cls._connect() + return cls._proxy.InsertMovieHash( + cls._login['token'], *args, **kwargs) + + +########################################################################## +# The RESOLVE command to get movie/series information from media details +class CommandResolve(Command): + command = 'resolve' + description = 'To get the movie/episode information from media details' + + def add_arguments(self, parser): + parser.add_argument( + '--meta', + help='The metadata provided by VLC', + ) + parser.add_argument( + '--hash', + dest='oshash', + help='The hash of the media for OpenSubtitles resolution', + ) + parser.add_argument( + '--size', + type=float, + help='The size of the media, in bytes', + ) + parser.add_argument( + '--duration', + type=float, + help='The duration of the media, in seconds', + ) + + def run(self, meta, oshash, size, duration): + # Prepare the parameters + meta = json.loads(meta) + + # Parse the filename to get more information + parsed = parse_filename(meta['filename']) + + ###################################################################### + # Internal function to search by hash + def search_hash(): + if not oshash: + return + + LOGGER.info('Searching media using hash research') + + # Search for the files corresponding to this hash + try: + medias = OpenSubtitlesAPI.check_hash([oshash, ]) + except Exception as e: + raise ResolveException(e) + + # If the hash is not in the results + medias = medias['data'] if 'data' in medias else [] + if oshash not in medias: + return + + # We're only interested in that hash + medias = medias[oshash] + + if len(medias) == 1: + # There is only one, so might as well be that one! + media = medias[0] + + # Unless it's not the same type... + if media['MovieKind'] not in parsed: + return + else: + # Initialize media to None in case we don't find anything + media = None + + # Search differently if it's an episode or a movie + if 'episode' in parsed: + episode = parsed['episode'] + season = episode['season'] + fepisode = episode['episodes'][0] + + # Define the prefix that characterize the show + show_prefix = '"{}"'.format(episode['show'].lower()) + + # And search if we find the first episode + for m in medias: + if m['MovieKind'] != 'episode' or \ + int(m['SeriesSeason']) != season or \ + int(m['SeriesEpisode']) != fepisode or \ + not m['MovieName'].lower().startswith( + show_prefix): + continue + + media = m + break + + # If we reach here and still haven't got the episode, try + # to see if we had maybe a typo in the name + if not media: + def weight_episode(x): + return fuzzywuzzy.fuzz.ratio( + parsed['episode']['show'], re.sub( + '^\"([^"]*)\" .*$', '\\1', x['MovieName'])) + + # Use fuzzywuzzy to get the closest show name + closest = max( + ( + m + for m in medias + if m['MovieKind'] == 'episode' and + int(m['SeriesSeason']) == season and + int(m['SeriesEpisode']) == fepisode + ), + key=weight_episode, + ) + if weight_episode(closest) >= .8: + media = closest + + if not media and 'movie' in parsed: + movie = parsed['movie'] + + media_name = movie.get('title') + + # Use fuzzywuzzy to get the closest movie name + media = max( + medias, + key=lambda x: fuzzywuzzy.fuzz.ratio( + media_name, x['MovieName']) + ) + + imdb = imdbpie.Imdb( + exclude_episodes=True, + ) + + try: + result = imdb.get_title( + 'tt{}'.format(media['MovieImdbID'])) + except Exception as e: + raise ResolveException(e) + + return imdb, result + + # If when reaching here we don't have the media, return None + if not media: + return + + # Else, we will need imdb for getting more detailed information on + # the media; we'll exclude episodes if we know the media is a movie + imdb = imdbpie.Imdb( + exclude_episodes=(media['MovieKind'] == 'movie'), + ) + + try: + result = imdb.get_title('tt{}'.format(media['MovieImdbID'])) + except Exception as e: + raise ResolveException(e) + + # Find the media + return imdb, result + + ###################################################################### + # Internal function to search by text for a movie + def search_text_movie(): + LOGGER.info('Searching media using text research on movie') + movie = parsed['movie'] + + # Initialize the imdb object to perform the research + imdb = imdbpie.Imdb( + exclude_episodes=True, + ) + + # Use imdb to search for the movie + try: + search = imdb.search_for_title(movie['title']) + except Exception as e: + raise ResolveException(e) + + if not search: + return + + year_found = False + for r in search: + r['fuzz_ratio'] = fuzzywuzzy.fuzz.ratio( + movie['title'], r['title']) + if movie['year'] and \ + not year_found and \ + r['year'] == movie['year']: + year_found = True + + if year_found: + search = [r for r in search if r['year'] == movie['year']] + + if not duration: + # If we don't have the movie duration, we won't be able to use + # it to discriminate the movies, so just use the highest ratio + max_ratio = max(r['fuzz_ratio'] for r in search) + search = [r for r in search if r['fuzz_ratio'] == max_ratio] + + # Even if there is multiple with the highest ratio, only + # return one + return imdb, imdb.get_title(search[0]['imdb_id']) + + # If we have the movie duration, we can use it to make the + # research more precise, so we can be more gentle on the ratio + sum_ratio = sum(r['fuzz_ratio'] for r in search) + mean_ratio = sum_ratio / float(len(search)) + std_dev_ratio = math.sqrt( + sum([ + math.pow(r['fuzz_ratio'] - mean_ratio, 2) + for r in search + ]) / float(len(search)) + ) + + # Select only the titles over a given threshold + threshold = mean_ratio + std_dev_ratio + search = [r for r in search if r['fuzz_ratio'] >= threshold] + + # Now we need to get more information to identify precisely + # the movie + for r in search: + r['details'] = imdb.get_title(r['imdb_id']) + + # Try to get the closest movie using the movie duration + # if available + closest = min( + search, + key=lambda x: + abs(x['details']['base']['runningTimeInMinutes'] * 60. - + duration) + if duration and + x['details']['base']['runningTimeInMinutes'] is not None + else sys.maxint + ) + + # Return the imdb information of the closest movie found + return imdb, closest['details'], closest['fuzz_ratio'] + + ###################################################################### + # Internal function to search by text for an episode + def search_text_episode(): + LOGGER.info('Searching media using text research on episode') + ep = parsed['episode'] + + # To allow to search on the tvdb + tvdb = tvdb_api.Tvdb( + cache=False, + language='en', + ) + + # Perform the search, if nothing is found, there's a problem... + try: + series = tvdb.search(ep['show']) + except Exception as e: + raise ResolveException(e) + + if not series: + return + + series = tvdb[series[0]['seriesName']] + episode = series[ep['season']][ep['episodes'][0]] + + # Initialize the imdb object to perform the research + imdb = imdbpie.Imdb( + exclude_episodes=False, + ) + + # Use imdb to search for the series using its name or aliases + search = None + for seriesName in [series['seriesName'], ] + series['aliases']: + try: + search = imdb.search_for_title(seriesName) + except Exception as e: + raise ResolveException(e) + + # Filter the results by name and type + search = [ + s for s in search + if s['type'] == 'TV series' and + s['title'] == seriesName + ] + + # If there is still more than one, filter by year + if len(search) > 1: + search = [ + s for s in search + if s['year'] == series['firstAired'][:4] + ] + + # If we have a series, we can stop there! + if search: + break + + # If we did not find anything that matches + if not search: + return + + # Get the series' seasons and episodes + series = imdb.get_title_episodes(search[0]['imdb_id']) + for season in series['seasons']: + if season['season'] != ep['season']: + continue + + for episode in season['episodes']: + if episode['episode'] != ep['episodes'][0]: + continue + + # id is using format /title/ttXXXXXX/ + return imdb, imdb.get_title(episode['id'][7:-1]) + + # Not found + return + + ###################################################################### + # Internal function to search by text + def search_text(): + search = None + if 'episode' in parsed: + try: + search = search_text_episode() + except ResolveException as e: + LOGGER.warning( + 'Exception when trying to search manually ' + 'for an episode: {}'.format(e)) + + if not search and 'movie' in parsed: + try: + search = search_text_movie() + except ResolveException as e: + LOGGER.warning( + 'Exception when trying to search manually ' + 'for a movie: {}'.format(e)) + + return search + + ###################################################################### + # Internal function to insert a hash + def insert_hash(media, ratio): + if not oshash or (parsed['type'] == 'movie' and ratio < 70.): + return + + LOGGER.info('Sending movie hash information to opensubtitles') + + # Insert the movie hash if possible! + media_duration = ( + duration * 1000.0 + if duration + else media['base']['runningTimeInMinutes'] * 60. * 1000. + ) + try: + res = OpenSubtitlesAPI.insert_hash( + [ + { + 'moviehash': oshash, + 'moviebytesize': size, + 'imdbid': media['base']['id'][9:-1], + 'movietimems': media_duration, + 'moviefilename': meta['filename'], + }, + ] + ) + except Exception as e: + raise ResolveException(e) + + LOGGER.info(res) + if res['status'] != '200 OK': + title = media['base']['title'] + if media['base']['titleType'] == 'tvEpisode': + title = '{} - S{:02d}E{:02d} - {}'.format( + media['base']['parentTitle']['title'], + media['base']['season'], + media['base']['episode'], + title, + ) + LOGGER.info('Unable to submit hash for \'{0}\': {1}'.format( + title, res['status'])) + elif oshash in res['data']['accepted_moviehashes']: + LOGGER.info('New hash submitted and accepted') + else: + LOGGER.info('New hash submitted but not accepted') + + ###################################################################### + # Logic of that function + + # To determine if we'll have to insert the hash + should_insert_hash = False + media = None + + # First search using the hash + try: + media = search_hash() + except ResolveException as e: + LOGGER.warning( + 'Exception when trying to resolve the hash: {}'.format(e)) + + # If not found, try using the information we can get from the metadata + # and the file name + if not media: + should_insert_hash = True + media = search_text() + + # If still not found, print an empty list, and return + if not media: + print('[]') + return + + # Split the imdb object so we can reuse the one that has been + # instanciated during the research + ratio = media[2] if len(media) > 2 else 0 + imdb, media = media[:2] + + if media['base']['titleType'] == 'tvEpisode': + parsed['type'] = 'episode' + else: + parsed['type'] = 'movie' + + # If we need to insert the hash, insert it for the first media found + # only - OpenSubtitles does not allow duplicates, and it will still + # allow for matches + if oshash and should_insert_hash: + try: + insert_hash(media, ratio) + except ResolveException as e: + LOGGER.warning( + 'Exception when trying to insert the hash: {}'.format(e)) + + # Return in the form of a list + media_list = [media, ] + + # If it was an episode, and we had more episodes in the list... + if parsed['type'] == 'episode' and \ + len(parsed['episode']['episodes']) > 1: + while len(media_list) < len(parsed['episode']['episodes']): + # id is using format /title/ttXXXXXX/ - We just want + # the ttXXXXXX + media = imdb.get_title(media['base']['nextEpisode'][7:-1]) + media_list.append(media) + + for m in media_list: + if m['base']['titleType'] == 'tvEpisode': + m_series = m['base']['parentTitle']['title'] + m_season = m['base']['season'] + m_ep = m['base']['episode'] + m_year = m['base']['parentTitle']['year'] + ids = resolve_episode_ids(m_series, m_season, m_ep, m_year) + else: + m_movie = m['base']['title'] + m_year = m['base']['year'] + ids = resolve_movie_ids(m_movie, m_year) + + # Append the found IDs to the media + for k, v in ids.items(): + m['base']['{}id'.format(k)] = v + + ###################################################################### + # Print the JSON dump of the media list + print(json.dumps(tobyte(media_list), sort_keys=True, + indent=4, separators=(',', ': '), + ensure_ascii=False)) diff --git a/helper/commands/runvlc.py b/helper/commands/runvlc.py new file mode 100644 index 0000000..d69b8c2 --- /dev/null +++ b/helper/commands/runvlc.py @@ -0,0 +1,95 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import logging +import subprocess + +from helper.utils import ( + Command, + get_vlc, + run_as_user, +) + +LOGGER = logging.getLogger(__name__) + + +########################################################################## +# The RUNVLC command to run VLC in a subprocess and get its output +class CommandRunVLC(Command): + command = 'runvlc' + description = ('To run VLC in a subprocess and get its output ' + 'even from Windows') + + def add_arguments(self, parser): + parser.add_argument( + 'parameters', + nargs='*', + default=[], + help='The command line parameters to pass to VLC', + ) + parser.add_argument( + '--vlc', + dest='vlc_bin', + help='To specify manually where the VLC executable is', + ) + + def run(self, vlc_bin, parameters): + # Try to find the VLC executable if it has not been passed as + # parameter + if not vlc_bin: + LOGGER.info('Searching for VLC binary...') + vlc_bin = get_vlc() + # If we still did not find it, cancel the installation as we will + # not be able to complete it + if not vlc_bin: + raise RuntimeError( + 'VLC executable not found: use the --vlc parameter ' + 'to specify VLC location') + + # Preparing the command + command = [ + vlc_bin, + ] + parameters + + LOGGER.info('Running command: {}'.format( + subprocess.list2cmdline(command))) + + run = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **run_as_user() + ) + try: + for line in iter(run.stdout.readline, b''): + line = line.strip() + print(line) + except KeyboardInterrupt: + run.kill() + finally: + run.stdout.close() + + return run.wait() diff --git a/helper/commands/service.py b/helper/commands/service.py new file mode 100644 index 0000000..9936511 --- /dev/null +++ b/helper/commands/service.py @@ -0,0 +1,236 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import io +import logging +import platform +import shlex +import socket + +from helper.parser import ( + parse_args, +) +from helper.utils import ( + Command, + redirectstd, +) + +if platform.system() == 'Windows': + import servicemanager + import win32event + import win32service + import win32serviceutil + +LOGGER = logging.getLogger(__name__) + + +########################################################################## +# The SERVICE command to start the TraktForVLC helper as a service that +# allows for requests and replies through TCP +class CommandService(Command): + command = 'service' + description = 'Start the TraktForVLC helper as a service' + + def add_arguments(self, parser): + parser.add_argument( + '--host', + help='Host to bind to [default: 127.0.0.1]', + default='localhost', + ) + parser.add_argument( + '--port', + type=int, + help='Port to bind to [default: 1984]', + default=1984, + ) + if platform.system() == 'Windows': + parser.add_argument( + 'action', + nargs='?', + type=str.lower, + choices=[ + 'install', + 'update', + 'remove', + 'start', + 'restart', + 'stop', + 'debug', + 'standalone', + ], + ) + + def run(self, host, port, action=None): + if platform.system() == 'Windows' and action != 'standalone': + return run_windows_service(action=action, host=host, port=port) + else: + return run_service(host=host, port=port) + + +############################################################################## +# Function called to run the Windows service, this can be used to install as +# well as to manage the service +def run_windows_service(action, host=None, port=None, exeName=None): + # Prepare the arguments that are going to be used with the service + # command line + args = ['TraktForVLC', ] + if action == 'install': + args.extend(['--startup', 'auto']) + if action is not None: + args.append(action) + + # Prepare the args that will be passed to the service executable + exe_args = [ + 'service', + ] + if host is not None: + exe_args.append('--host={}'.format(host)) + if port is not None: + exe_args.append('--port={}'.format(port)) + + # Prepare the class that represents the Windows Service + class TraktForVLCWindowsService(win32serviceutil.ServiceFramework): + _svc_name_ = 'TraktForVLC' + _svc_display_name_ = 'TraktForVLC' + _svc_description_ = 'TraktForVLC helper tool' + _exe_name_ = exeName + _exe_args_ = ' '.join(exe_args) + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) + socket.setdefaulttimeout(60) + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + win32event.SetEvent(self.hWaitStop) + + def SvcDoRun(self): + rc = None # noqa: F841 + + def stop_condition(): + rc = win32event.WaitForSingleObject(self.hWaitStop, 0) + return rc == win32event.WAIT_OBJECT_0 + + try: + run_service(host, port, stop_condition=stop_condition) + except Exception as e: + LOGGER.exception(e) + raise + + if len(args) == 1: + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(TraktForVLCWindowsService) + servicemanager.StartServiceCtrlDispatcher() + else: + win32serviceutil.HandleCommandLine(TraktForVLCWindowsService, + argv=args) + + +############################################################################## +# Function called to run the actual service that will listen and reply to +# commands +def run_service(host, port, stop_condition=lambda: False): + # Create the TCP/IP socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Bind it to the host and port + LOGGER.info('Starting server on {} port {}'.format(host, port)) + sock.bind((host, port)) + + try: + # Put the socket in server mode + sock.listen(1) + + # Process connections + while not stop_condition(): + # Wait for a connection to be opened + LOGGER.debug('Waiting for a connection') + try: + sock.settimeout(5) + conn, client = sock.accept() + except socket.timeout: + # If the wait times out, just start waiting again + continue + + # Now the connection is established, do something + try: + LOGGER.info('Connection from {}'.format(client)) + + req = io.BytesIO() + req_ready = False + while not req_ready: + conn.settimeout(15.0) + data = conn.recv(2048) + print('Received "{}"'.format(data)) + if not data: + print('No more data') + break + else: + if '\n' in data: + data = data.splitlines()[0] + req_ready = True + req.write(data) + + LOGGER.debug('Request: {}'.format(req.getvalue())) + + output = io.BytesIO() + exit = 0 + try: + with redirectstd(output): + args, action, params = parse_args( + args=shlex.split(req.getvalue())) + + action_exit = action(**params) + if action_exit is not None: + exit = action_exit + except SystemExit as e: + exit = e + except Exception as e: + LOGGER.error(e, exc_info=True) + exit = -1 + finally: + conn.sendall('Exit: {}\n'.format(exit)) + conn.sendall(output.getvalue()) + LOGGER.debug('Sent: Exit {}\n{}'.format( + exit, output.getvalue())) + except socket.timeout: + LOGGER.info('Connection with {} timed out'.format(client)) + continue + except socket.error as e: + LOGGER.info('Connection with {} ended in error: {}'.format( + client, e)) + continue + finally: + # Clean up the client connection + conn.close() + except Exception as e: + LOGGER.exception(e) + raise + finally: + sock.close() + + LOGGER.info('Stopping.') diff --git a/helper/commands/uninstall.py b/helper/commands/uninstall.py new file mode 100644 index 0000000..e693e9d --- /dev/null +++ b/helper/commands/uninstall.py @@ -0,0 +1,343 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +from concurrent.futures import TimeoutError +import io +import logging +import os +import platform +import subprocess +import sys +import time + +from helper.commands.install import ( + CommandInstallUpdateDelete, +) +from helper.commands.service import ( + run_windows_service, +) +from helper.utils import ( + ask_yes_no, + get_os_config, + get_vlc, + redirectstd, + run_as_user, +) +from helper.version import ( + __version__, +) + +LOGGER = logging.getLogger(__name__) + + +########################################################################## +# The UNINSTALL command to uninstall TraktForVLC +class CommandUninstall(CommandInstallUpdateDelete): + command = 'uninstall' + description = 'To install TraktForVLC' + + def add_arguments(self, parser): + super(CommandUninstall, self).add_arguments(parser) + + if platform.system() == 'Windows': + parser.add_argument( + '--max-wait', + type=int, + help='Maximum time to wait for files to be deleted on Windows', + ) + + def run(self, dry_run, yes, system, service, service_host, service_port, + vlc_bin, vlc_config, vlc_lua, max_wait=None): + if service and platform.system() != 'Windows': + LOGGER.error('The service mode is not supported yet for {}'.format( + platform.system())) + + os_config = get_os_config(system or service, vlc_config, vlc_lua) + + # Try to find the VLC executable if it has not been passed as parameter + if not vlc_bin: + LOGGER.info('Searching for VLC binary...') + vlc_bin = get_vlc() + # If we still did not find it, cancel the installation as we will + # not be able to complete it + if not vlc_bin: + raise RuntimeError( + 'VLC executable not found: use the --vlc parameter ' + 'to specify VLC location') + else: + LOGGER.info('VLC binary: {}'.format(vlc_bin)) + + # Compute the path to the directories we need to use after + lua = os_config['lua'] + lua_intf = os.path.join(lua, 'intf') + + # Search for the files to remove + to_remove = [] + + search_files = { + # The helper + lua: [ + 'trakt_helper', + 'trakt_helper.exe', + 'trakt_helper.py', + ], + # The Lua interfaces + lua_intf: [ + 'trakt.lua', + 'trakt.luac', + ], + } + + for path, files in sorted(search_files.items()): + if not files: + continue + + LOGGER.info('Searching for the files to remove in {}'.format(path)) + for f in files: + fp = os.path.join(path, f) + if os.path.isfile(fp): + to_remove.append(fp) + + # Show to the user what's going to be done + if not to_remove: + print('No files to be removed. Will still try to disable ' + 'trakt\'s lua interface.') + me = None + else: + to_remove.sort() + if getattr(sys, 'frozen', False): + me = sys.executable + else: + me = os.path.realpath(__file__) + + # If the executable is in the list of files to remove, push it to + # the end, we only want it to be removed if we succeeded in + # removing everything else + if me in to_remove: + to_remove.remove(me) + to_remove.append(me) + + print('Will remove the following files:') + for f in to_remove: + if f == me: + print(' - {} (this is me!!)'.format(f)) + else: + print(' - {}'.format(f)) + print('And try to disable trakt\'s lua interface.') + + # Prompt before continuing, except if --yes was used + if not yes: + yes_no = ask_yes_no('Proceed with uninstallation ?') + if not yes_no: + print('Uninstallation aborted by {}.'.format( + 'signal' if yes_no is None else 'user')) + return + + # We then need to start VLC with the trakt interface enabled, and + # pass the autostart=disable parameter so we'll disable the interface + # from VLC + LOGGER.info('Setting up VLC not to use trakt\'s interface') + configured = False + if not dry_run: + command = [ + vlc_bin, + '-I', 'luaintf', + '--lua-intf', 'trakt', + '--lua-config', 'trakt={autostart="disable"}', + ] + LOGGER.debug('Running command: {}'.format( + subprocess.list2cmdline(command))) + disable = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **run_as_user() + ) + output = [] + msg_ok = ('[trakt] lua interface: VLC is configured ' + 'not to use TraktForVLC') + msg_not_exists = ('[trakt] lua interface error: Couldn\'t find ' + 'lua interface script "trakt".') + for line in iter(disable.stdout.readline, b''): + line = line.strip() + output.append(line) + LOGGER.debug(line) + if line.endswith(msg_ok) or line.endswith(msg_not_exists): + configured = True + disable.stdout.close() + + if disable.wait() != 0 and not configured: + LOGGER.error('Unable to disable VLC ' + 'lua interface:\n{}'.format('\n'.join(output))) + return -1 + else: + configured = True + + if configured: + LOGGER.info('VLC configured') + else: + LOGGER.error('Error while configuring VLC') + return -1 + + # We can then stop and remove the service + if service: + service_exists = True + + LOGGER.info('Stopping TraktForVLC service') + if not dry_run: + output = io.BytesIO() + with redirectstd(output): + run_windows_service(action='stop') + + service_stop_lines = output.getvalue().splitlines() + if service_stop_lines[-1] == 'Stopping service TraktForVLC': + LOGGER.info('Service stopped') + elif service_stop_lines[-1].endswith('(1062)'): + LOGGER.info('Service already stopped') + elif service_stop_lines[-1].endswith('(1060)'): + LOGGER.info('Service does not seem to be installed') + service_exists = False + else: + for l in service_stop_lines: + LOGGER.error(l) + return -1 + + if service_exists: + LOGGER.info('Removing TraktForVLC service') + if not dry_run: + output = io.BytesIO() + with redirectstd(output): + run_windows_service(action='remove') + + service_remove_lines = output.getvalue().splitlines() + if service_remove_lines[-1] == 'Service removed': + LOGGER.info(service_remove_lines[-1]) + elif service_remove_lines[-1].endswith('(1060)'): + LOGGER.info('Service does not seem to be installed') + service_exists = False + else: + for l in service_remove_lines: + LOGGER.error(l) + + if not dry_run: + start = time.time() + while service_exists: + if max_wait is not None and start + max_wait > time.time(): + LOGGER.error( + 'Reached maximum wait time and the service ' + 'is still not removed; Aborting.') + return -1 + + LOGGER.info('Checking service...') + output = io.BytesIO() + with redirectstd(output): + run_windows_service(action='remove') + + service_remove_lines = output.getvalue().splitlines() + if service_remove_lines[-1].endswith('(1060)'): + LOGGER.info('Service is entirely removed') + service_exists = False + else: + # Wait 5 seconds, then check again + time.sleep(5) + + # If there is files to remove, wait to get the right to remove them... + # this is only needed for Windows + if platform.system() == 'Windows': + LOGGER.info('Checking that the files can be deleted...') + open_files = [] + start = time.time() + interrupt = False + try: + for fname in to_remove: + if fname == me: + # Ignore if the file is ourselves, this will be managed + # differently, but for other files it is important + # to check + continue + + f = None + while not f: + if max_wait is not None and \ + start + max_wait > time.time(): + raise TimeoutError + + if not os.path.isfile(fname): + break + + try: + f = open(fname, 'a') + open_files.append(f) + except Exception as e: + if isinstance(e, KeyboardInterrupt): + raise + LOGGER.debug(e) + LOGGER.debug( + 'Waiting 5 seconds before retrying...') + time.sleep(5) + except KeyboardInterrupt: + LOGGER.info('Aborting.') + interrupt = None + except TimeoutError: + LOGGER.info('Timed out. Aborting.') + interrupt = -1 + finally: + # Close the files we were able to open + while open_files: + open_files.pop().close() + + if interrupt is not False: + return interrupt + + # Then we remove the files + for f in to_remove: + LOGGER.info('Removing {}'.format(f)) + if not dry_run: + try: + os.remove(f) + except WindowsError: + if f != me: + # If we got a windows error while trying to remove one + # of the files that is not the currently running file, + # raise the error + raise + + LOGGER.info('Cannot remove myself directly, launching a ' + 'subprocess to do so... Bye bye ;(') + command = subprocess.list2cmdline([ + # Run that as a shell command + 'CMD', '/C', + # Wait two seconds - So the file descriptor is freed + 'PING', '127.0.0.1', '-n', '2', '>NUL', '&', + # Delete the file + 'DEL', '/F', '/S', '/Q', f, + ]) + LOGGER.debug('Running command: {}'.format(command)) + subprocess.Popen(command) + return + + LOGGER.info('TraktForVLC{} is now uninstalled. :('.format( + ' {}'.format(__version__) if me in to_remove else '')) diff --git a/helper/commands/update.py b/helper/commands/update.py new file mode 100644 index 0000000..1f17a54 --- /dev/null +++ b/helper/commands/update.py @@ -0,0 +1,364 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import ( + absolute_import, + print_function, +) +import json +import logging +import os +from pkg_resources import parse_version +import platform +import re +import requests +import stat +import struct +import subprocess +import tempfile + +from helper.commands.install import ( + CommandInstallUpdateDelete, +) +from helper.utils import ( + ask_yes_no, +) +from helper.version import ( + __version__, +) + +LOGGER = logging.getLogger(__name__) + + +########################################################################## +# The UPDATE command to check if there are new versions available +class CommandUpdate(CommandInstallUpdateDelete): + command = 'update' + description = ('To check and update if there are new versions of ' + 'TraktForVLC available') + + def add_arguments(self, parser): + super(CommandUpdate, self).add_arguments(parser) + + version_update = parser.add_mutually_exclusive_group() + version_update.add_argument( + '-t', '--release-type', + help='The type of updates to look for; any type will also allow ' + 'for a more stable type (i.e. beta also allows for rc and ' + 'stable) as long as the update is more recent', + type=str.lower, + choices=[ + 'latest', + 'alpha', + 'beta', + 'rc', + 'stable', + ], + default='stable', + ) + version_update.add_argument( + '--version', + help='The version to look for and install; will be used only to ' + 'check for the tag corresponding to that version.', + ) + version_update.add_argument( + '--file', + dest='filepath', + help='The file to use for install; no check will be performed, ' + 'and the installation step will be launched directly with ' + 'that file.', + ) + action_update = parser.add_mutually_exclusive_group() + action_update.add_argument( + '--download', + dest='action', + action='store_const', + const='download', + help='If an update is found, it will only be downloaded', + ) + action_update.add_argument( + '--install', + dest='action', + action='store_const', + const='install', + help='If an update is found, it will be downloaded and the ' + 'install process will automatically be started', + ) + parser.add_argument( + '--ignore-dev', + action='store_true', + help='Run the update process even if the current version is the ' + 'development one', + ) + output_update = parser.add_mutually_exclusive_group() + output_update.add_argument( + '--discard-install-output', + dest='install_output', + action='store_const', + const=False, + help='If the --install option is used, the output generated from ' + 'the installation will be silenced', + ) + output_update.add_argument( + '--install-output', + help='If the --install option is used, will put the output ' + 'generated from the installation process in the file ' + 'specified here', + ) + + def run(self, dry_run, yes, system, service, service_host, service_port, + vlc_bin, vlc_config, vlc_lua, release_type, version, filepath, + action, ignore_dev, install_output): + if service and platform.system() != 'Windows': + LOGGER.error('The service mode is not supported yet for {}'.format( + platform.system())) + + if not ignore_dev and __version__ == '0.0.0a0.dev0': + LOGGER.debug('This is a development version; update is ' + 'not available') + print('{}') + return + + if filepath or version: + release_type = None + + ret = {} + + if release_type or version: + if release_type: + resp = requests.get( + 'https://api.github.com/repos/XaF/testingTFV/releases') + # 'https://api.github.com/repos/XaF/TraktForVLC/releases') + + if not resp.ok: + raise RuntimeError('Unable to get the releases from ' + 'GitHub (return code {})'.format( + resp.status_code)) + + # Determine what release types we will accept during the check + releases_level = { + 'latest': 0, + 'alpha': 1, 'a': 1, + 'beta': 2, 'b': 2, + 'rc': 3, + 'stable': 4, + } + search_level = releases_level[release_type] + + # Determine the format of the asset we will check to download + system = platform.system().lower() + if system == 'darwin': + system = 'osx' + elif system == 'windows': + arch = struct.calcsize('P') * 8 + if arch == 32: + system = '{}_x86'.format(system) + elif arch == 64: + system = '{}_x64'.format(system) + system = '{}.exe'.format(system) + asset_re = re.compile('^TraktForVLC_(?P.*)_{}$'.format( + re.escape(system))) + + # Then try and go through the available releases on GitHub to check + # for the most recent one fitting our parameters + found_release = None + for release in resp.json(): + if release_type: + release_level = None + if release['tag_name'] == 'latest': + # This is the latest release + release_level = releases_level['latest'] + else: + parsed = parse_version(release['tag_name']) + if isinstance(parsed._version, str): + continue + + # Check if it is a prerelease or an actual release + if parsed.is_prerelease or release['prerelease']: + if not release['prerelease']: + LOGGER.debug( + 'Release {} considered as prerelease ' + 'by parse_version but not on ' + 'GitHub!'.format(release['tag_name'])) + elif not parsed.is_prerelease: + LOGGER.debug( + 'Release {} considered as prerelease ' + 'on GitHub but not by parse_version, ' + 'ignoring it.'.format(release['tag_name'])) + continue + + # Determine the type of prerelease + release_level = releases_level.get( + parsed._version.pre[0]) + else: + # This is a stable release + release_level = releases_level['stable'] + + # If this release does not match our needs, go to next loop + if release_level is None or release_level < search_level: + continue + elif version != release['tag_name']: + continue + + # Check in the assets that we have what we need + for asset in release['assets']: + m = asset_re.search(asset['name']) + if not m: + continue + + version = release['tag_name'] + if version == 'latest': + version = m.group('version') + # We might have the commit id, the pr number or a dirty + # flag in the version, this needs to be in the 'local' + # part of the version number, but GitHub replaces '+' + # signs by a dot in the assert names, we thus need to + # replace it back + version = re.sub( + '(\.(dirty|pr[0-9]|g[a-z0-9]+)){1,3}$', + '+\g<1>', version) + version = version.replace('+.', '+') + + found_release = { + 'version': version, + 'asset_name': asset['name'], + 'asset_url': asset['browser_download_url'], + } + break + + # If we found a release, stop now + if found_release: + break + + if found_release: + # We found a release, but we need to check that its version is + # greater than ours; if it's not the case, it means that we are + # at the most recent version currently. + current_v = parse_version(__version__) + release_v = parse_version(found_release['version']) + if current_v >= release_v: + found_release = None + + if not found_release: + # No release found, we can just stop now, there is no update + LOGGER.debug('No release found') + print('{}') + return + + # We found an update + ret['version'] = found_release['version'] + + # If we are not going to download nor install, stop there + if action is None: + print(json.dumps(ret, sort_keys=True, + indent=4, separators=(',', ': '))) + return + + if not yes: + yes_no = ask_yes_no('Download file {} ?'.format( + found_release['asset_name'])) + if not yes_no: + print('Installation aborted by {}.'.format( + 'signal' if yes_no is None else 'user')) + return + + filepath = os.path.join(tempfile.gettempdir(), + found_release['asset_name']) + + resp = requests.get(found_release['asset_url']) + if not resp.ok: + raise RuntimeError('Error retrieving the asset: {}'.format( + resp.status_code)) + + with open(filepath, 'wb') as fout: + fout.write(resp.content) + + if platform.system() != 'Windows': + LOGGER.info('Setting permissions of {} to 755'.format( + filepath)) + os.chmod(filepath, + stat.S_IRWXU | + stat.S_IRGRP | stat.S_IXGRP | + stat.S_IROTH | stat.S_IXOTH) + + ret['downloaded'] = True + + # If we are not going to install, stop there + if action != 'install': + print(json.dumps(ret, sort_keys=True, + indent=4, separators=(',', ': '))) + return + + if not yes: + yes_no = ask_yes_no('Install {} ?'.format( + os.path.basename(filepath))) + if not yes_no: + print('Installation aborted by {}.'.format( + 'signal' if yes_no is None else 'user')) + return + + # We just launch the installation. If it can be done right now, it + # will be, if it cannot, it will wait, that is the job of the + # installer as of now! + command = [ + filepath, + '--loglevel', logging.getLevelName(LOGGER.getEffectiveLevel()), + ] + if install_output: + command.extend(['--logfile', install_output]) + + command.extend(['install', '--yes']) + if vlc_bin: + command.extend(['--vlc', vlc_bin]) + if vlc_lua: + command.extend(['--vlc-lua-directory', vlc_lua]) + if vlc_config: + command.extend(['--vlc-config-directory', vlc_config]) + if system: + command.append('--system') + if service: + command.extend([ + '--service', + '--service-host', service_host, + '--service-port', str(service_port), + ]) + if dry_run: + command.append('--dry-run') + + LOGGER.debug('Running command: {}'.format( + subprocess.list2cmdline(command))) + + output = {} + if install_output is not None: + output['stdout'] = subprocess.PIPE + output['stderr'] = subprocess.PIPE + else: + output['stderr'] = subprocess.STDOUT + output['stdin'] = subprocess.PIPE + + subprocess.Popen( + command, + **output + ) + + ret['installing'] = True + print(json.dumps(ret, sort_keys=True, + indent=4, separators=(',', ': '))) diff --git a/helper/parser.py b/helper/parser.py new file mode 100644 index 0000000..911f599 --- /dev/null +++ b/helper/parser.py @@ -0,0 +1,276 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import print_function +import argparse +import inspect +import logging +import platform +import sys +import types + +from helper.version import ( + __version__, + __release_name__, + __build_date__, + __build_system__, + __author__, +) + +LOGGER = logging.getLogger(__name__) + + +############################################################################## +# Class defining the KeepLineBreaksFormatter description help formatter that +# aims at keeping the line breaks in the help, while providing text wrapping +# facilities +class KeepLineBreaksFormatter(argparse.RawDescriptionHelpFormatter): + def _fill_text(self, text, width, indent): + return '\n'.join(['\n'.join(argparse._textwrap.wrap(line, width)) + for line in text.splitlines()]) + + +############################################################################## +# Allow to use --xx and --no-xx options +class ActionYesNo(argparse.Action): + def __init__(self, option_strings, dest, default=None, + required=False, help=None): + + opt = option_strings[0] + if not opt.startswith('--'): + raise ValueError('Yes/No arguments must be prefixed with --') + + if opt.startswith('--no-'): + opt = opt[5:] + if default is None: + default = True + else: + opt = opt[2:] + if default is None: + default = False + + # Save the option as attribute + self.opt = opt + + # List of options available for that + opts = ['--{}'.format(opt), '--no-{}'.format(opt)] + + # Check that all other options are acceptable + for extra_opt in option_strings[1:]: + if not extra_opt.startswith('--'): + raise ValueError('Yes/No arguments must be prefixed with --') + if not extra_opt.endswith('-{}'.format(opt)): + raise ValueError( + 'Only single argument is allowed with Yes/No action') + + opts.append(extra_opt) + + super(ActionYesNo, self).__init__( + opts, dest, nargs=0, const=None, default=default, + required=required, help=help) + + def __call__(self, parser, namespace, values, option_strings=None): + if option_strings == '--{}'.format(self.opt): + setattr(namespace, self.dest, True) + elif option_strings == '--no-{}'.format(self.opt): + setattr(namespace, self.dest, False) + else: + opt = option_strings[2:-len(self.opt) - 1] + boolean = True + if opt.startswith('no-'): + boolean = False + opt = opt[3:] + elif opt.endswith('-no'): + boolean = False + opt = opt[:-3] + setattr(namespace, self.dest, (boolean, opt)) + + +############################################################################## +# Class defining the PrintVersion argument parser to allow for short and +# long versions +class PrintVersion(argparse.Action): + def __init__(self, help='show program\'s version number and exit', + *args, **kwargs): + super(PrintVersion, self).__init__( + nargs=0, default=argparse.SUPPRESS, help=help, *args, **kwargs) + + def __call__(self, parser, args, values, option_string=None): + if option_string == '--short-version': + print(__version__) + sys.exit(0) + + version_desc = [] + version_desc.append('TraktForVLC {}{}{}'.format( + __version__, + ' "{}"'.format(__release_name__) if __release_name__ else '', + ' for {}'.format(__build_system__) if __build_system__ else '')) + version_desc.append('Copyright (C) 2017-2018 {}'.format(__author__)) + if __build_date__: + version_desc.append('Built on {}'.format(__build_date__)) + version_desc.extend([ + '', + 'This program is distributed in the hope that it will be useful,', + 'but WITHOUT ANY WARRANTY; without even the implied warranty of', + 'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the', + 'GNU General Public License (version 2) for more details.', + '', + 'Source available: https://github.com/XaF/TraktForVLC', + ]) + print('\n'.join(version_desc)) + sys.exit(0) + + +############################################################################## +# Function that aims at parsing the arguments received; these arguments can +# be received from the command line or from the running service instance +def parse_args(args=None, preparse=False, parser_type=argparse.ArgumentParser): + ########################################################################## + # Create a preparser that allows us to know everything that we need to + # know before actually parsing and processing the arguments + preparser = parser_type( + add_help=False, + ) + preparser.add_argument( + '-d', '--debug', + dest='loglevel', + default='DEFAULT', + action='store_const', const='DEBUG', + help='Activate debug output') + preparser.add_argument( + '-q', '--quiet', + dest='loglevel', + default='DEFAULT', + action='store_const', const='ERROR', + help='Only show errors or critical log messages') + preparser.add_argument( + '--loglevel', + dest='loglevel', + default='DEFAULT', + choices=('NOTSET', 'DEBUG', 'INFO', + 'WARNING', 'ERROR', 'CRITICAL'), + help='Define the specific log level') + preparser.add_argument( + '--logformat', + default='%(asctime)s::%(levelname)s::%(message)s', + help='To change the log format used') + preparser.add_argument( + '--logfile', + help='To write the logs to a file instead of the standard output') + + if platform.system() == 'Windows': + preparser.add_argument( + '-k', '--keep-alive', + help='To keep the console window alive at the end of the command', + action='store_true', + ) + + ########################################################################## + # Parse the known arguments of the preparser + preargs, left_args = preparser.parse_known_args(args) + if preparse: + return preargs, left_args + + ########################################################################## + # Now create the parser that we will use to actually parse the arguments + parser = parser_type( + description='TraktForVLC helper tool, providing an easy way to ' + 'install/uninstall TraktForVLC, as well as all the ' + 'commands and actions that cannot be performed directly ' + 'from the Lua VLC interface.\n\n' + 'This program is distributed in the hope that it will be ' + 'useful, but WITHOUT ANY WARRANTY; without even the ' + 'implied warranty of MERCHANTABILITY or FITNESS FOR A ' + 'PARTICULAR PURPOSE. See the GNU General Public License ' + '(version 2) for more details.\n\n' + 'Source available: https://github.com/XaF/TraktForVLC', + formatter_class=KeepLineBreaksFormatter, + parents=[preparser, ] + ) + + ########################################################################## + # Parameters available for the tool in general + parser.add_argument( + '-V', '--version', '--short-version', + action=PrintVersion) + + ########################################################################## + # To define the commands available with the helper and separate them + commands = parser.add_subparsers( + help='Helper command', + dest='command', + ) + + command_instances = {} + import helper.commands + # Go through the submodules in helper.commands to find the commands to + # be made available + for mname in dir(helper.commands): + # If the object name starts with '_', discard it + if mname.startswith('_'): + continue + + # Else, check if the object is actually a module + m = getattr(helper.commands, mname) + if not isinstance(m, types.ModuleType): + continue + + # If it was a module, go through the objects in it + for cname in dir(m): + # If the object name stats with '_', discard it + if cname.startswith('_'): + continue + + # Else, check if the object is a class, and if it is a subclass + # of the helper.utils.Command class + c = getattr(m, cname) + if not inspect.isclass(c) or \ + not issubclass(c, helper.utils.Command): + continue + + # Finally, check if the command and description are defined for + # the module + if c.command is None or c.description is None: + continue + + command_parser = commands.add_parser( + c.command, + help=c.description, + ) + command_instances[c.command] = c() + command_instances[c.command].add_arguments(command_parser) + + ########################################################################## + # Parse the arguments + args = parser.parse_args(args) + + ########################################################################## + # Check the arguments for the command requested + command_instances[args.command].check_arguments(parser, args) + + ########################################################################## + # Prepare the parameters to be passed to that function + params = {k: v for k, v in vars(args).items()} + del params['command'] + for k in vars(preargs): + del params[k] + + return args, command_instances[args.command].run, params diff --git a/helper/utils.py b/helper/utils.py new file mode 100644 index 0000000..27ea60d --- /dev/null +++ b/helper/utils.py @@ -0,0 +1,190 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +from __future__ import print_function +import contextlib +import distutils.spawn +import glob +import logging +import os +import platform +import sys + +if platform.system() != 'Windows': + import pwd + +LOGGER = logging.getLogger(__name__) + + +############################################################################## +# Get a resource from the same directory as the helper, or from the binary +# if we are currently in the binary. +def get_resource_path(relative_path): + return os.path.join( + getattr( + sys, '_MEIPASS', + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + ), + relative_path + ) + + +############################################################################## +# Context manager that allows to temporarily redirect what is written to +# stdout and stderr to another stream received as parameter +@contextlib.contextmanager +def redirectstd(output): + old_stdout, sys.stdout = sys.stdout, output + old_stderr, sys.stderr = sys.stderr, output + try: + yield output + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + + +############################################################################## +# Class representing a command that will be available through the helper +class Command(object): + command = None + description = None + + def add_arguments(self, parser): + pass + + def check_arguments(self, parser, args): + pass + + def run(self, *args, **kwargs): + pass + + +############################################################################## +# Return the path to the VLC executable +def get_vlc(): + if platform.system() == 'Windows': + environment = ['ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432'] + program_files = set( + os.environ[e] for e in environment if e in os.environ) + for p in program_files: + fpath = os.path.join(p, 'VideoLAN', 'VLC', 'vlc.exe') + if os.path.isfile(fpath): + return fpath + return distutils.spawn.find_executable('vlc') + + +############################################################################## +# To run a subprocess as user +def run_as_user(): + if platform.system() == 'Windows' or not os.getenv('SUDO_USER'): + LOGGER.debug('No need to change the user') + return {} + + pw = pwd.getpwnam(os.getenv('SUDO_USER')) + LOGGER.debug('Providing the parameters to run the command as {}'.format( + pw.pw_name)) + + env = os.environ.copy() + for k in env.keys(): + if k.startswith('SUDO_'): + del env[k] + + env['HOME'] = pw.pw_dir + env['LOGNAME'] = env['USER'] = env['USERNAME'] = pw.pw_name + + def demote(): + os.setgid(pw.pw_gid) + os.setuid(pw.pw_uid) + + return { + 'preexec_fn': demote, + 'env': env, + } + + +############################################################################## +# To determine the paths to the LUA and Config directories of VLC +def get_os_config(system=None, config=None, lua=None): + def itmerge(*iterators): + for iterator in iterators: + for value in iterator: + yield value + + if not config or not lua: + opsys = platform.system() + if opsys in ['Linux', 'Darwin']: + if os.getenv('SUDO_USER'): + home = pwd.getpwnam(os.getenv('SUDO_USER')).pw_dir + else: + home = os.path.expanduser('~') + if opsys == 'Linux': + if not config: + config = os.path.join(home, '.config', 'vlc') + if not lua: + if system: + lua = next(itmerge( + glob.iglob('/usr/lib/*/vlc/lua'), + glob.iglob('/usr/lib/vlc/lua'), + )) + else: + lua = os.path.join(home, '.local', 'share', 'vlc', 'lua') + elif opsys == 'Darwin': + if not config: + config = os.path.join(home, 'Library', 'Application Support', + 'org.videolan.vlc') + if not lua: + if system: + lua = '/Applications/VLC.app/Contents/MacOS/share/lua' + else: + lua = os.path.join(config, 'lua') + elif opsys == 'Windows': + if not config: + config = os.path.join(os.getenv('APPDATA'), 'vlc') + if not lua: + if system: + lua = os.path.join( + os.getenv('PROGRAMFILES'), 'VideoLAN', 'VLC', 'lua') + else: + lua = os.path.join(config, 'lua') + + if config and lua: + return { + 'config': config, + 'lua': lua, + } + + raise RuntimeError('Unsupported operating system: {}'.format(system)) + + +############################################################################## +# To prompt the user for a yes-no answer +def ask_yes_no(prompt): + try: + while 'the feeble-minded user has to provide an answer': + reply = str(raw_input( + '{} [y/n] '.format(prompt))).lower().strip() + if reply in ['y', 'yes', '1']: + return True + elif reply in ['n', 'no', '0']: + return False + except (KeyboardInterrupt, EOFError): + print('Installation aborted by signal.') + return diff --git a/helper/version.py b/helper/version.py new file mode 100644 index 0000000..e1281d2 --- /dev/null +++ b/helper/version.py @@ -0,0 +1,27 @@ +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + + +__version__ = '0.0.0a0.dev0' +__release_name__ = '' +__build_date__ = '' +__build_system__ = '' +__author__ = 'Raphaël Beamonte ' diff --git a/trakt.lua b/trakt.lua new file mode 100644 index 0000000..89db865 --- /dev/null +++ b/trakt.lua @@ -0,0 +1,2764 @@ +--[==========================================================================[ + trakt.lua: Trakt.tv Interface for VLC +--[==========================================================================[ + TraktForVLC, to link VLC watching to trakt.tv updating + + Copyright (C) 2017-2018 Raphaël Beamonte + $Id$ + + This file is part of TraktForVLC. TraktForVLC is free software: + you can redistribute it and/or modify it under the terms of the GNU + General Public License as published by the Free Software Foundation, + version 2. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software Foundation, + Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + or see . +--]==========================================================================] + +-- TraktForVLC version +local __version__ = '0.0.0a0.dev0' + +-- The location of the helper` +local path_to_helper + +------------------------------------------------------------------------------ +-- Local modules +------------------------------------------------------------------------------ +-- The variable that will store the ospath module +local ospath = {} +-- The variable that will store the requests module +local requests = {} +-- The variable that will store the timers module +local timers = {} +-- The variable that will store the trakt module +local trakt = {} +-- The variable that will store the file module +local file = {} + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- LOAD DEPENDENCIES -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- To work with JSON data +local json = require('dkjson') +-- To do math operations +local math = require('math') + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- OSPATH MODULE TO PERFORM OPERATIONS ON PATHS -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +------------------------------------------------------------------------------ +-- Variables +------------------------------------------------------------------------------ +-- The default separator for the current file system +ospath.sep = package.config:sub(1,1) + +------------------------------------------------------------------------------ +-- Function to return the path to the current lua script, this will not work +-- on all cases as some instances of VLC only return the script's name +------------------------------------------------------------------------------ +function ospath.this() + return debug.getinfo(2, "S").source:sub(2) +end + + +------------------------------------------------------------------------------ +-- Function to check if a path exists +------------------------------------------------------------------------------ +function ospath.exists(path) + if type(path) ~= "string" then return false end + local ok, err, code = os.rename(path, path) + if not ok then + if code == 13 then + return true + end + end + return ok, err +end + + +------------------------------------------------------------------------------ +-- Function to check if a path is a file +------------------------------------------------------------------------------ +function ospath.isfile(path) + if type(path) ~= "string" then return false end + if not ospath.exists(path) then return false end + local f = io.open(path) + if f then + local ff = f:read(1) + f:close() + if not ff then + return false + end + return true + end + return false +end + + +------------------------------------------------------------------------------ +-- Function to check if a path is a directory +------------------------------------------------------------------------------ +function ospath.isdir(path) + return (ospath.exists(path) and not ospath.isfile(path)) +end + + +------------------------------------------------------------------------------ +-- Function to get the directory name from a path +------------------------------------------------------------------------------ +function ospath.dirname(path) + local d = path:match("^(.*)" .. ospath.sep .. "[^" .. ospath.sep .. "]*$") + if not d or d == '' then + return path + end + return d +end + + +------------------------------------------------------------------------------ +-- Function to get the base name from a path +------------------------------------------------------------------------------ +function ospath.basename(path) + local b = path:match("^.*" .. ospath.sep .. + "([^" .. ospath.sep .. "]*)" .. + ospath.sep .. "?$") + if not b or b == '' then + return path + end + return b +end + + +------------------------------------------------------------------------------ +-- Function to join multiple elements using the file system's separator +------------------------------------------------------------------------------ +function ospath.join(...) + local arg + if type(...) == 'table' then + arg = ... + else + arg = {...} + end + local path + for _, p in pairs(arg) do + if not path or p.sub(1, 1) == ospath.sep then + path = p + else + if string.sub(path, -string.len(ospath.sep)) ~= ospath.sep then + path = path .. ospath.sep + end + path = path .. p + end + end + return path +end + + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- FUNCTIONS THAT ARE PROVIDING UTILITIES FOR THE REST OF THIS INTERFACE -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +------------------------------------------------------------------------------ +-- Sleeps for a given duration (in microseconds) +-- @param microseconds The duration in microseconds +------------------------------------------------------------------------------ +local +function usleep(microseconds) + vlc.misc.mwait(vlc.misc.mdate() + microseconds) +end + + +------------------------------------------------------------------------------ +-- Sleeps for a given duration (in seconds) +-- @param seconds The duration in seconds +------------------------------------------------------------------------------ +local +function sleep(seconds) + usleep(seconds * 1000000) +end + + +------------------------------------------------------------------------------ +-- Repeat a function every delay for a duration +-- @param delay The delay (in microseconds) +-- @param duration The duration (in microseconds) +-- @param func The function to execute +------------------------------------------------------------------------------ +local +function ucallrepeat(delay, duration, func) + local repeat_until = vlc.misc.mdate() + duration + while vlc.misc.mdate() < repeat_until do + local ret = func() + if ret ~= nil then return ret end + vlc.misc.mwait(vlc.misc.mdate() + delay) + end +end + + +------------------------------------------------------------------------------ +-- Repeat a function every delay for a duration +-- @param delay The delay (in seconds) +-- @param duration The duration (in seconds) +-- @param func The function to execute +------------------------------------------------------------------------------ +local +function callrepeat(delay, duration, func) + return ucallrepeat(delay * 1000000, duration * 1000000, func) +end + + +------------------------------------------------------------------------------ +-- Dumps the object passed as argument recursively and returns a string that +-- can then be printed. +-- @param o The object to dump +-- @param lvl The current level of indentation +------------------------------------------------------------------------------ +local dump_func_path = {} +local +function dump(o,lvl) + local lvl = lvl or 0 + local indent = '' + for i=1,lvl do indent = indent .. '\t' end + + if type(o) == 'table' then + local s = '{\n' + for k,v in pairs(o) do + if type(k) == 'function' then + if not dump_func_path[k] then + finf = debug.getinfo(k) + dump_func_path[k] = string.gsub( + finf['source'], "(.*/)(.*)", "%2") .. + ':' .. finf['linedefined'] + end + k = '"' .. dump_func_path[k] .. '"' + elseif type(k) ~= 'number' then + k = '"' .. k .. '"' + end + s = s .. indent .. '\t[' .. k .. '] = ' .. + dump(v, lvl+1) .. ',\n' + end + return s .. indent .. '}' + elseif type(o) == 'string' then + return '"' .. o .. '"' + else + return tostring(o) + end +end + + +-- Taken from https://stackoverflow.com/questions/20325332/how-to-check-if- +-- two-tablesobjects-have-the-same-value-in-lua +------------------------------------------------------------------------------ +-- Performs a deep comparison that can be applied on tables +-- @param o1 The first object to compare +-- @param o2 The second object to compare +-- @param ignore_mt Whether or not to ignore the built-in equal method +------------------------------------------------------------------------------ +local +function equals(o1, o2, ignore_mt) + if o1 == o2 then return true end + local o1Type = type(o1) + local o2Type = type(o2) + if o1Type ~= o2Type then return false end + if o1Type ~= 'table' then return false end + + if not ignore_mt then + local mt1 = getmetatable(o1) + if mt1 and mt1.__eq then + --compare using built in method + return o1 == o2 + end + end + + local keySet = {} + + for key1, value1 in pairs(o1) do + local value2 = o2[key1] + if value2 == nil or equals(value1, value2, ignore_mt) == false then + return false + end + keySet[key1] = true + end + + for key2, _ in pairs(o2) do + if not keySet[key2] then return false end + end + return true +end + + +-- Taken from http://trac.opensubtitles.org/projects/opensubtitles/wiki/ +-- HashSourceCodes#Lua +------------------------------------------------------------------------------ +-- Allows to get the hash that can then be used on opensubtitles to perform a +-- research for the file information +-- @param fileName The path to the filename for which to compute the hash +------------------------------------------------------------------------------ +local +function movieHash(fileName) + local fil = assert(io.open(fileName, 'rb')) + local lo, hi = 0, 0 + for i = 1, 8192 do + local a, b, c, d = fil:read(4):byte(1, 4) + lo = lo + a + b * 256 + c * 65536 + d * 16777216 + a, b, c, d = fil:read(4):byte(1, 4) + hi = hi + a + b * 256 + c * 65536 + d * 16777216 + while lo >= 4294967296 do + lo = lo - 4294967296 + hi = hi + 1 + end + while hi >= 4294967296 do + hi = hi - 4294967296 + end + end + local size = fil:seek('end', -65536) + 65536 + for i=1,8192 do + local a, b, c, d = fil:read(4):byte(1, 4) + lo = lo + a + b * 256 + c * 65536 + d * 16777216 + a, b, c, d = fil:read(4):byte(1, 4) + hi = hi + a + b * 256 + c * 65536 + d * 16777216 + while lo >= 4294967296 do + lo = lo - 4294967296 + hi = hi + 1 + end + while hi >= 4294967296 do + hi = hi - 4294967296 + end + end + lo = lo + size + while lo >= 4294967296 do + lo = lo - 4294967296 + hi = hi + 1 + end + while hi >= 4294967296 do + hi = hi - 4294967296 + end + fil:close() + return string.format('%08x%08x', hi, lo), size +end + + +-- Taken from https://stackoverflow.com/questions/11163748/open-web-browser- +-- using-lua-in-a-vlc-extension +------------------------------------------------------------------------------ +-- Open an URL in the client browser +-- @param url The URL to open +------------------------------------------------------------------------------ +local open_cmd +local +function open_url(url) + if not open_cmd then + if package.config:sub(1,1) == '\\' then -- windows + open_cmd = function(url) + -- Should work on anything since (and including) win'95 + os.execute(string.format('start "%s"', url)) + end + -- the only systems left should understand uname... + elseif (io.popen("uname -s"):read'*a') == "Darwin" then -- OSX/Darwin ? (I can not test.) + open_cmd = function(url) + -- I cannot test, but this should work on modern Macs. + os.execute(string.format('open "%s"', url)) + end + else -- that ought to only leave Linux + open_cmd = function(url) + -- should work on X-based distros. + os.execute(string.format('xdg-open "%s"', url)) + end + end + end + + open_cmd(url) +end + + +------------------------------------------------------------------------------ +-- Function to get the path to the helper if not provided +------------------------------------------------------------------------------ +function get_helper() + local search_in = {} + local trakt_helper + + -- Check first if we don't have any configuration value telling us where + -- the helper is located + local cfg_location = {} + + -- Or as an environment variable + if os.getenv('TRAKT_HELPER') and os.getenv('TRAKT_HELPER') ~= '' then + table.insert(cfg_location, os.getenv('TRAKT_HELPER')) + end + + -- Or, most common way, in the module's configuration + if trakt.config and + trakt.config.helper and + trakt.config.helper.location and + trakt.config.helper.location ~= '' then + table.insert(cfg_location, trakt.config.helper.location) + end + + -- If we have any of those indication, try to use it + for _, v in pairs(cfg_location) do + if v then + if v == '~' or string.sub(v, 1, 2) == '~/' then + v = ospath.join(vlc.config.homedir(), + string.sub(v, 3)) + elseif os.getenv('PWD') and ( + (ospath.sep == '/' and + string.sub(v, 1, 1) ~= '/') or + (ospath.sep == '\\' and + not string.match(v, '^[a-zA-Z]:\\'))) then + v = ospath.join(os.getenv('PWD'), v) + end + if not ospath.exists(v) then + vlc.msg.err('File not found: ' .. v) + return + elseif ospath.isdir(v) then + table.insert(search_in, v) + break + else + trakt_helper = v + break + end + end + end + + -- Else, fall back on searching manually, first in VLC's + -- config directory for any local install, else on the + -- lua directory if we got enough information to do it + if not trakt_helper then + for _, dir in ipairs(vlc.config.datadir_list('')) do + table.insert(search_in, dir) + end + + local files = { + 'trakt_helper', + 'trakt_helper.exe', + 'trakt_helper.py', + } + for _, d in pairs(search_in) do + for _, f in pairs(files) do + fp = ospath.join(d, f) + if ospath.isfile(fp) then + return fp + end + end + end + end + return trakt_helper +end + + +------------------------------------------------------------------------------ +-- Function to facilitate the calls to perform to the helper +-- @param args The command line arguments to be sent to the helper +------------------------------------------------------------------------------ +local +function call_helper(args, discard_stderr) + if trakt.config.helper.mode ~= 'service' then + -- Add the helper path to the beginning of the args + table.insert(args, 1, path_to_helper) + end + + -- Escape the arguments + for k, v in pairs(args) do + v = v:gsub('\\"', '\\\\\\"') + v = v:gsub('"', '\\"') + v = '"' .. v .. '"' + args[k] = v + end + + -- Concatenate them to generate the command + local command = table.concat(args, ' ') + vlc.msg.dbg('(call_helper) Executing command: ' .. command) + + local response + local exit_code + if trakt.config.helper.mode == 'service' then + local maxtry = 1 + local try = 0 + while try < maxtry do + try = try + 1 + local sent = -2 + local fd = vlc.net.connect_tcp(trakt.config.helper.service.host, + trakt.config.helper.service.port) + if fd then + sent = vlc.net.send(fd, command .. '\n') + end + + if not fd then + vlc.msg.err('Unable to connect to helper on ' .. + trakt.config.helper.service.host .. ':' .. + trakt.config.helper.service.port) + elseif sent < 0 then + vlc.msg.err('Unable to send request to helper on ' .. + trakt.config.helper.service.host .. ':' .. + trakt.config.helper.service.port) + vlc.net.close(fd) + else + local pollfds = { + [fd] = vlc.net.POLLIN, + } + vlc.net.poll(pollfds) + + response = "" + local buf = vlc.net.recv(fd, 2048) + + -- Get the rest of the message + while buf and #buf > 0 do + vlc.msg.dbg('Reading buffer; content = ' .. buf) + response = response .. buf + + vlc.net.poll(pollfds) + buf = vlc.net.recv(fd, 2048) + end + + -- Close the connection + vlc.net.close(fd) + + -- Try and get the exit code + vlc.msg.dbg('Received data before parsing = ' .. response) + exit_code, response = response:match('^Exit: (-?[0-9]+)\n(.*)') + if exit_code ~= nil then + vlc.msg.dbg('Parsed EXIT_CODE = ' .. exit_code) + vlc.msg.dbg('Parsed RESPONSE = ' .. response) + exit_code = tonumber(exit_code) + break + end + end + end + + if not response then + vlc.msg.err('Unable to get command output') + return nil + end + elseif ospath.sep == '\\' then + vlc.msg.err('Only the service mode is available on Windows. ' .. + 'Standalone mode pops up a window every few ' .. + 'seconds... Who would have thought \'Windows\' ' .. + 'was so literal?! ;(') + return nil + else + if not discard_stderr then + command = command .. ' 2>&1' + end + + -- Run the command, and get the output + local fpipe = assert(io.popen(command, 'r')) + response = assert(fpipe:read('*a')) + local closed, exit_reason, exit_code = fpipe:close() + -- Lua 5.1 do not manage properly exit codes when using io.popen, + -- so if we are using Lua 5.1, or if the exit code is 'nil', we + -- will skip that step of checking the exit code. In any case, + -- if there was an issue, the json parsing will fail and we will + -- be able to catch that error + if _VERSION == 'Lua 5.1' then + exit_code = nil + end + end + + if exit_code ~= nil and exit_code ~= 0 then + -- We got a problem... + vlc.msg.err('(call_helper) Command: ' .. command) + vlc.msg.err('(call_helper) Command exited with code ' .. tostring(exit_code)) + vlc.msg.err('(call_helper) Command output: ' .. response) + return nil + end + + vlc.msg.dbg('(call_helper) Received response: ' .. tostring(response)) + + -- Decode the JSON returned as response, and check for errors + local obj, pos, err = json.decode(response) + if err then + vlc.msg.err('(call_helper) Command: ' .. command) + vlc.msg.err('(call_helper) Unable to parse json') + vlc.msg.err('(call_helper) Command output: ' .. response) + return nil + end + + -- Return the response object + return obj +end + + +------------------------------------------------------------------------------ +-- Function to merge a number of intervals provided in the parameter, in order +-- to get the lowest number of internals that cover the same area as all the +-- previous intervals +------------------------------------------------------------------------------ +local +function merge_intervals(data) + -- Sort the data + table.sort( + data, + function(a, b) + return (a.from < b.from or + (a.from == b.from and a.to < b.to)) + end + ) + + -- Prepare local variables + local merged = {} + local current = nil + + -- Go through the intervals to merge them together + for k, intv in pairs(data) do + if current and intv.from <= current.to then + current.to = math.max(intv.to, current.to) + else + if current then + table.insert(merged, current) + end + current = intv + end + end + + -- If we have a current data, merge it + if current then + table.insert(merged, current) + end + + -- Return the merged intervals + return merged +end + + +------------------------------------------------------------------------------ +-- Function that sums the data represented in form of intervals; the sum +-- represents the total area covered by the entirety of the intervals +------------------------------------------------------------------------------ +local +function sum_intervals(data) + local sum = 0 + + for k, intv in pairs(data) do + sum = sum + (intv.to - intv.from) + end + + return sum +end + + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- FUNCTIONS PROVIDING TIMER FACILITIES -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +------------------------------------------------------------------------------ +-- Variables +------------------------------------------------------------------------------ +-- The table containing the registered timers +timers._registered = {} + + +------------------------------------------------------------------------------ +-- To register a timer +-- @param func The function to run +-- @param delay The delay to run that function +------------------------------------------------------------------------------ +function timers.register(func, delay, expire) + -- If delay is nil, unregister the timer + if delay == nil then + timers._registered[func] = nil + return + end + + timers._registered[func] = { + ['delay'] = delay, + ['last'] = -1, + ['expire'] = expire, + } +end + + +------------------------------------------------------------------------------ +-- To run the timers that need to be run, will return the list of timers with +-- 'true' or 'false' as value whether or not they have been run +------------------------------------------------------------------------------ +function timers.run() + ran_timers = {} + cur_time = vlc.misc.mdate() + for f, d in pairs(timers._registered) do + -- If the timer expired, remove it + if d['expire'] and d['expire'] < cur_time then + timers._registered[f] = nil + + -- If we haven't passed the delay to run the timer, don't run it + elseif d['last'] + d['delay'] > cur_time then + ran_timers[f] = false + + -- Else, we can run the timer and update the information + else + f() + timers._registered[f]['last'] = cur_time + ran_timers[f] = true + end + end + + return ran_timers +end + + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- FUNCTIONS PROVIDING HTTP REQUESTS FACILITIES -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +------------------------------------------------------------------------------ +-- To perform a HTTP request +-- @param method The method to use for the request +-- @param url The URL to perform the request to +-- @param headers The headers to define for the request +-- @param body The body of the request +-- @param getbody Whether or not to return the body of the response +------------------------------------------------------------------------------ +function requests.http_request(...) + -------------------------------------------------------------------------- + -- Parse the arguments to allow either positional args or named args + local method, url, headers, body, getbody + if type(...) == 'table' then + local args = ... + method = args.method or args[1] + url = args.url or args[2] + headers = args.headers or args[3] + body = args.body or args[4] + else + method, url, headers, body, getbody = ... + end + + -------------------------------------------------------------------------- + -- Perform checks on the arguments + if not method then + error({message='No method provided'}) + end + method = string.upper(method) + if not url then + error({message='No URL provided for ' .. method .. ' request'}) + end + headers = headers or {} + headers['User-Agent'] = 'TraktForVLC ' .. __version__ .. + '/VLC ' .. vlc.misc.version() + + -------------------------------------------------------------------------- + -- Function logic + + -- Prepare the arguments to call the helper + args = { + 'requests', + method, + url, + } + + if headers then + table.insert(args, '--headers') + table.insert(args, json.encode(headers)) + end + if body then + table.insert(args, '--data') + table.insert(args, json.encode(body)) + end + + -- Return the response object + return call_helper(args) +end + + +------------------------------------------------------------------------------ +-- To perform a HTTP GET request +-- @param url The URL to perform the request to +-- @param headers The headers to define for the request +------------------------------------------------------------------------------ +function requests.get(...) + -- Parse the arguments to allow either positional args or named args + local url, headers + if type(...) == 'table' then + local args = ... + url = assert(args.url or args[1]) + headers = assert(args.headers or args[2]) + else + url, headers = ... + end + + -- Function logic + return requests.http_request{ + method='GET', + url=url, + headers=headers, + } +end + + +------------------------------------------------------------------------------ +-- To perform a HTTP POST request +-- @param url The URL to perform the request to +-- @param headers The headers to define for the request +------------------------------------------------------------------------------ +function requests.post(...) + -- Parse the arguments to allow either positional args or named args + local url, headers, body + if type(...) == 'table' then + local args = ... + url = assert(args.url or args[1]) + headers = assert(args.headers or args[2]) + body = assert(args.body or args[3]) + else + url, headers, body = ... + end + + -- Function logic + return requests.http_request{ + method='POST', + url=url, + headers=headers, + body=body, + } +end + + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- FUNCTIONS TO MANAGE THE CONFIGURATION FILE OF THE INTERFACE -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +------------------------------------------------------------------------------ +-- Variables +------------------------------------------------------------------------------ +-- Variable to store the path to the configuration file +local config_file = ospath.join(vlc.config.configdir(), 'trakt_config.json') +-- Variable to store the path to the cache file +local cache_file = ospath.join(vlc.config.configdir(), 'trakt_cache.json') +-- Last cache save time +local last_cache_save = -1 + + +------------------------------------------------------------------------------ +-- Returns the JSON data read from the file at filepath +-- @param filepath The path to the file to read the data from +-- @param default The default data returned if there is an error +------------------------------------------------------------------------------ +function file.get_json(filepath, default) + local data = {} + local file = io.open(filepath, 'r') + + if file then + data = json.decode(file:read('*a')) + file:close() + + if type(data) == 'table' then + return data + else + vlc.msg.err('(file.get_json) JSON file not in the right format') + end + else + vlc.msg.info('No JSON file found at ' .. filepath) + end + + return default +end + + +------------------------------------------------------------------------------ +-- Writes the JSON passed as argument to the file at filepath +-- @param filepath The path to the file to write the data to +-- @param data The table containing the JSON data to write +------------------------------------------------------------------------------ +function file.save_json(filepath, data) + local file = io.open(filepath, 'w') + if file then + file:write(json.encode(data, { indent = true })) + file:close() + else + error('Error opening the file ' .. filepath .. ' to save') + end +end + + +------------------------------------------------------------------------------ +-- Returns the configuration read from the configuration file +------------------------------------------------------------------------------ +local +function get_config() + local lconfig = file.get_json(config_file, {}) + + -- Default configuration version + if not lconfig.config_version then + lconfig.config_version = __version__ + end + + -- Default cache config + if not lconfig.cache then + lconfig.cache = {} + end + if not lconfig.cache.delay then + lconfig.cache.delay = {} + end + if not lconfig.cache.delay.save then + lconfig.cache.delay.save = 30 -- 30 seconds + end + if not lconfig.cache.delay.cleanup then + lconfig.cache.delay.cleanup = 60 -- 60 seconds + end + if not lconfig.cache.delay.expire then + lconfig.cache.delay.expire = 2592000 -- 30 days + end + + -- Default media config + if not lconfig.media then + lconfig.media = {} + end + if not lconfig.media.info then + lconfig.media.info = {} + end + if not lconfig.media.info.max_try then + lconfig.media.info.max_try = 10 + end + if not lconfig.media.info.try_delay_factor then + lconfig.media.info.try_delay_factor = 30 -- 30 seconds + end + if not lconfig.media.start then + lconfig.media.start = {} + end + if not lconfig.media.start.time then + lconfig.media.start.time = 30 -- 30 seconds + end + if not lconfig.media.start.percent then + lconfig.media.start.percent = .25 -- 0.25% + end + if not lconfig.media.start.movie then + lconfig.media.start.movie = true + end + if not lconfig.media.start.episode then + lconfig.media.start.episode = true + end + if not lconfig.media.stop then + lconfig.media.stop = {} + end + if not lconfig.media.stop.watched_percent then + lconfig.media.stop.watched_percent = 50 -- 50% + end + if not lconfig.media.stop.percent then + lconfig.media.stop.percent = 90 -- 90% + end + if not lconfig.media.stop.movie then + lconfig.media.stop.movie = true + end + if not lconfig.media.stop.episode then + lconfig.media.stop.episode = true + end + if not lconfig.media.stop.check_unprocessed_delay then + lconfig.media.stop.check_unprocessed_delay = 120 -- 120 seconds + end + + -- Default helper config + if not lconfig.helper then + lconfig.helper = {} + end + if not lconfig.helper.mode then + lconfig.helper.mode = 'standalone' -- Can be one of 'standalone', 'service' + end + + -- Default helper service config + if not lconfig.helper.service then + lconfig.helper.service = {} + end + if not lconfig.helper.service.host then + lconfig.helper.service.host = 'localhost' + end + if not lconfig.helper.service.port then + lconfig.helper.service.port = 1984 + end + + -- Default helper update config + if not lconfig.helper.update then + lconfig.helper.update = {} + end + if not lconfig.helper.update.check_delay then + lconfig.helper.update.check_delay = 86400 -- 24 hours, set to 0 to disable + end + if not lconfig.helper.update.release_type then + lconfig.helper.update.release_type = 'stable' -- Can be one of 'stable', + -- 'rc', 'beta', 'alpha' or + -- 'latest' + end + if not lconfig.helper.update.action then + lconfig.helper.update.action = 'install' -- Can be one of 'install', + -- 'download' or 'check' + end + + return lconfig +end + + +------------------------------------------------------------------------------ +-- Writes the configuration to the configuration file +-- @param config The table containing the configuration +------------------------------------------------------------------------------ +local +function save_config(config) + return file.save_json(config_file, config) +end + + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- FUNCTIONS PROVIDING THE TRAKT LOGIN AND UPDATES -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +------------------------------------------------------------------------------ +-- Variables +------------------------------------------------------------------------------ +trakt.base_url = 'https://api.trakt.tv' +trakt.api_key = '0e59f99095515c228d5fbc104e342574' .. + '941aeeeda95946b8fa50b2b0366609bf' +trakt.api_sec = '3ed1d013ef80eb0bb45d8da8424b4b61' .. + '3713abb057ed505683caf0baf1b5c650' +trakt.api_version = 2 +trakt.config = get_config() +if trakt.config.auth and + trakt.config.auth.access_token and + trakt.config.auth.refresh_token then + trakt.configured = true +else + trakt.configured = false +end + +------------------------------------------------------------------------------ +-- Function that allows to start the authentication protocol with trakt.tv +-- through the device code; it will provide the URL and code to use to +-- allow TraktForVLC to work with trakt.tv +------------------------------------------------------------------------------ +function trakt.device_code() + if trakt.configured then + error('TraktForVLC is already configured for Trakt.tv') + end + + local url = trakt.base_url .. '/oauth/device/code' + local headers = { + ['Content-Type'] = 'application/json', + ['trakt-api-key'] = trakt.api_key, + ['trakt-api-version'] = trakt.api_version, + } + local body = { + ['client_id'] = trakt.api_key, + } + + -- Query the API to get a device code + resp = requests.post{ + url=url, + headers=headers, + body=body, + } + if not resp or resp.status_code ~= 200 then + error('Unable to generate the device code ' .. + 'for Trakt.tv authentication') + end + + -- If everything went fine, we should have the information that + -- we need to provide the user for its authentication + json_body = resp.json + + -- Prepare the message to print that information + message = { + 'TraktForVLC is not setup with Trakt.tv yet!', + '--', + 'PLEASE GO TO ' .. json_body['verification_url'], + 'AND ENTER THE FOLLOWING CODE:', + json_body['user_code'], + } + + -- Prepare a local function to print the message to the console + local function print_msg_console() + vlc.msg.err('\n\t' .. + '############################' .. + '\n\t' .. + table.concat(message, '\n\t') .. + '\n\t' .. + '############################') + end + + -- Prepare a local function to print the message to an OSD channel + local osd_channel + local function print_msg_osd(duration) + if osd_channel then + vlc.osd.message( + table.concat(message, '\n'), + osd_channel, + 'center', + duration + ) + end + end + + -- Print it a first time to the console + print_msg_console() + + -- Then check every interval if the data is ready + local left_reset_play = 2 + local was_playing = false + local message_date = vlc.misc.mdate() + local poll_url = trakt.base_url .. '/oauth/device/token' + local got_token = callrepeat( + json_body['interval'], + json_body['expires_in'], + function() + vlc.msg.dbg('Checking if token has been granted') + + if vlc.input.is_playing() then + -- We're going to be awful, but if the person starts to play + -- and TraktForVLC is not configured yet, we're gonna prevent + -- the media to be played + if vlc.playlist.status() == 'playing' then + if left_reset_play == 0 then + -- If we reached the maximum of pauses we force, just + -- abandon now! + return false + end + left_reset_play = left_reset_play - 1 + + vlc.playlist.pause() + was_playing = true + print_msg_console() + end + -- If we can, use an OSD channel to print the message to the + -- user directly on the media screen... that should be + -- visible! + if not osd_channel then + osd_channel = vlc.osd.channel_register() + print_msg_osd(600000000 - (vlc.misc.mdate() - message_date)) + end + end + + -- Prepare the request to the API to verify if we've got + -- auth tokens ready + local body = { + ['client_id'] = trakt.api_key, + ['client_secret'] = trakt.api_sec, + ['code'] = json_body['device_code'], + } + + -- Query the API (we use the same headers as before) + resp = requests.post{ + url=poll_url, + headers=headers, + body=body, + } + + if not resp then + vlc.msg.err('Error when trying to check the ' .. + 'auth token status') + return false + elseif resp.status_code == 400 then + vlc.msg.dbg('Auth token pending (Waiting for ' .. + 'the user to authorize the app)') + elseif resp.status_code == 429 then + vlc.msg.dbg('Got asked to slow down by the API') + sleep(2) + elseif resp.status_code ~= 200 then + vlc.msg.err('(check_token) Request returned with code ' .. + tostring(resp.status_code)) + if resp.json then + vlc.msg.err('(check_token) Request body: ' .. + dump(resp.json)) + else + vlc.msg.err('(check_token) Request body: ' .. + dump(resp.body)) + end + return false + end + + -- If we reach here, we got status 200, hence the + -- tokens are ready + return resp.json + end + ) + + -- If we're here, clear the OSD channel, it's not useful anymore + if osd_channel then + vlc.osd.channel_clear(osd_channel) + end + + -- If we reach here and we did not have any token... it did not + -- work... ;( We'll just disable TraktForVLC until VLC is restarted + if not got_token then + message = { + 'TraktForVLC setup failed; Restart VLC to try again', + '(disabled until then)' + } + print_msg_console() + print_msg_osd(5) + return false + end + + -- Show a quick thank you message :) + message = { + 'TraktForVLC is now setup with Trakt.tv!', + 'Thank you :)', + } + print_msg_console() + print_msg_osd(5) + + -- If the media was playing and we paused it, put it back in play + if was_playing then + vlc.playlist.play() + end + + -- Save the tokens information + if not trakt.config['auth'] then + trakt.config['auth'] = {} + end + trakt.config.auth.access_token = got_token.access_token + trakt.config.auth.refresh_token = got_token.refresh_token + save_config(trakt.config) + + trakt.configured = true + return true +end + + +------------------------------------------------------------------------------ +-- Function that allows to renew authentication tokens using the refresh +-- token; this will allow to keep TraktForVLC authenticated with trakt.tv +-- even after the current auth token has expired. +------------------------------------------------------------------------------ +function trakt.renew_token() + if not trakt.configured then + error('TraktForVLC is not configured for Trakt.tv') + end + vlc.msg.dbg('Renewing tokens') + + local url = trakt.base_url .. '/oauth/token' + local headers = { + ['Content-Type'] = 'application/json', + ['trakt-api-key'] = trakt.api_key, + ['trakt-api-version'] = trakt.api_version, + } + local body = { + ['refresh_token'] = trakt.config.auth.refresh_token, + ['client_id'] = trakt.api_key, + ['client_secret'] = trakt.api_sec, + ['redirect_uri'] = 'urn:ietf:wg:oauth:2.0:oob', + ['grant_type'] = 'refresh_token', + } + + -- Query the API to get the new tokens + resp = requests.post{ + url=url, + headers=headers, + body=body, + } + + if not resp then + vlc.msg.err('Error when trying to refresh token') + return false + elseif resp.status_code == 401 then + vlc.msg.err('Refresh token is invalid') + + -- Erase the current auth config... not working + -- anymore anyway + trakt.configured = false + trakt.config.auth = {} + save_config(trakt.config) + + -- Restart the device_code process! + return trakt.device_code() + elseif resp.status_code ~= 200 then + vlc.msg.err('Error when trying to refresh the tokens: ' .. + resp.status_code .. ' ' .. resp.status_text) + return false + end + + -- If we reach here, we got status 200, hence the + -- tokens are ready + got_token = resp.json + + -- Save the new tokens + trakt.config.auth.access_token = got_token.access_token + trakt.config.auth.refresh_token = got_token.refresh_token + save_config(trakt.config) + + vlc.msg.dbg('Tokens renewed') + return true +end + + +------------------------------------------------------------------------------ +-- Function that allows to scrobble with trakt.tv; this will allow to start, +-- pause, and stop scrobbling. +------------------------------------------------------------------------------ +function trakt.scrobble(action, media, percent) + if not trakt.configured then + error('TraktForVLC is not configured for Trakt.tv') + elseif not media['imdb'] then + return false + end + if not percent then + percent = media['ratio']['local'] * 100. + end + vlc.msg.info(action:sub(1,1):upper() .. action:sub(2) .. + ' scrobbling ' .. media['name']) + + local url = trakt.base_url .. '/scrobble/' .. action:lower() + local headers = { + ['Content-Type'] = 'application/json', + ['trakt-api-key'] = trakt.api_key, + ['trakt-api-version'] = trakt.api_version, + ['Authorization'] = string.format( + 'Bearer %s', trakt.config.auth.access_token) + } + local body = { + [media['type']] = { + ['ids'] = { + ['imdb'] = string.sub(media.imdb.id, 8, -2), + ['tvdb'] = media.imdb.tvdbid, + ['tmdb'] = media.imdb.tmdbid, + }, + }, + ['progress'] = percent, + ['app_version'] = __version__, + } + + local try = 0 + while true do + -- Query the API to scrobble + resp = requests.post{ + url=url, + headers=headers, + body=body, + } + + if not resp then + vlc.msg.err('Error when trying to ' .. action:lower() .. + ' scrobble') + return false + elseif resp.status_code == 401 then + if try > 0 then + vlc.msg.err('Unable to scrobble') + return false + end + vlc.msg.info('Got 401 while scrobbling, trying to reauth') + trakt.renew_token() + try = try + 1 + elseif resp.status_code == 409 then + vlc.msg.info('Already scrobbled recently') + return true + elseif resp.status_code ~= 201 then + vlc.msg.err('Error when trying to ' .. action:lower() .. + ' scrobble: ' .. tostring(resp.status_code) .. ' ' .. + tostring(resp.reason)) + vlc.msg.dbg(dump(resp)) + return false + else + return resp.json + end + end +end + + +------------------------------------------------------------------------------ +-- Function to cancel the currently watching status on trakt.tv +------------------------------------------------------------------------------ +function trakt.cancel_watching(media) + -- As per the Trakt API v2, we need to call the start method saying that + -- the watch is at the end, so it will expire soon after. + return trakt.scrobble('start', media, 99.99) +end + + +------------------------------------------------------------------------------ +-- Function to add to trakt.tv history for medias that we could not scrobble +-- in real time (no internet connection, issue identifying media at the time, +-- etc.) +------------------------------------------------------------------------------ +function trakt.add_to_history(medias) + if not trakt.configured then + error('TraktForVLC is not configured for Trakt.tv') + end + vlc.msg.dbg('Syncing past views with trakt') + + local url = trakt.base_url .. '/sync/history' + local headers = { + ['Content-Type'] = 'application/json', + ['trakt-api-key'] = trakt.api_key, + ['trakt-api-version'] = trakt.api_version, + ['Authorization'] = string.format( + 'Bearer %s', trakt.config.auth.access_token) + } + + local movies = {} + local episodes = {} + + for k, v in pairs(medias) do + if v.imdbid and v.type and v.watched_at then + local data = { + ['watched_at'] = v.watched_at, + ['ids'] = { + ['imdb'] = v.imdbid, + ['tvdb'] = v.tvdbid, + ['tmdb'] = v.tmdbid, + }, + } + if v.type == 'movie' then + table.insert(movies, data) + else + table.insert(episodes, data) + end + end + end + + if next(movies) == nil and next(episodes) == nil then + error('Nothing to sync ?!') + end + vlc.msg.info('Syncing ' .. tostring(#movies) .. ' movie(s) and ' .. + tostring(#episodes) .. ' episode(s) with Trakt.tv') + + local body = {} + if next(movies) ~= nil then + body['movies'] = movies + end + if next(episodes) ~= nil then + body['episodes'] = episodes + end + + local try = 0 + while true do + -- Query the API to add to history + resp = requests.post{ + url=url, + headers=headers, + body=body, + } + + if not resp then + vlc.msg.err('Error when trying to add to history') + return false + elseif resp.status_code == 401 then + if try > 0 then + vlc.msg.err('Unable to add to history') + return false + end + vlc.msg.info('Got 401 while adding to history, trying to reauth') + trakt.renew_token() + try = try + 1 + elseif resp.status_code ~= 201 then + vlc.msg.err('Error when trying to add to history: ' .. + tostring(resp.status_code) .. ' ' .. + tostring(resp.status_text)) + return false + else + return resp.json + end + end +end + + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- FUNCTIONS THAT ARE RELATED TO THE CACHE -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +------------------------------------------------------------------------------ +-- Returns the cache read from the cache file +------------------------------------------------------------------------------ +local +function get_cache() + return file.get_json(cache_file, {}) +end + + +------------------------------------------------------------------------------ +-- Variables +------------------------------------------------------------------------------ +-- Variable containing the cached information +local cache = get_cache() +-- To inform if the cache has changed +local cache_changed = false + + +------------------------------------------------------------------------------ +-- Writes the cache to the cache file +-- @param cache The table containing the cache +-- @param force Whether or not to force the save of the cache right now (else, +-- data might be saved only at a next call after the delay has +-- expired) +------------------------------------------------------------------------------ +local +function save_cache(cache, force) + if not force and + vlc.misc.mdate() < (trakt.config.cache.delay.save * 1000000. + + last_cache_save) then + return + end + last_cache_save = vlc.misc.mdate() + cache_changed = false + return file.save_json(cache_file, cache) +end + + +------------------------------------------------------------------------------ +-- Function that cleanup the cache of all the media that were not used +-- recently and that are not waiting to be added to trakt.tv history +------------------------------------------------------------------------------ +local +function cleanup_cache() + -- If there is no delay for cache data to expire, do not do anything + if not trakt.config.cache.delay.expire then + vlc.msg.dbg('No cache expiration, nothing to do') + return + end + + -- Get the current date + date = call_helper({'date', '--format', '%s.%f'}) + if not date or not date.date then + vlc.msg.err('(cleanup_cache) Unable to get the current date') + return + end + date = tonumber(date.date) + + for k, v in pairs(cache) do + local no_wait_scrobble = (not v['scrobble_ready'] or + next(v['scrobble_ready']) == nil) + local expired = ( + not v['last_use'] or ( + v['last_use'] + trakt.config.cache.delay.expire) < date) + if no_wait_scrobble and expired then + cache[k] = nil + cache_changed = true + end + end + + if cache_changed then + vlc.msg.info('Saving cache') + save_cache(cache, true) + end +end + + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- FUNCTIONS THAT ARE RELATED TO THE PROCESSING OF THE MEDIA INFORMATION -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +------------------------------------------------------------------------------ +-- Variables +------------------------------------------------------------------------------ +-- Variable containing the information about the last media seen +local last_infos = nil +-- Variable containing the information about the current watching status +local watching = nil + + +------------------------------------------------------------------------------ +-- Gets the current play time for the item being played currently +------------------------------------------------------------------------------ +local +function get_play_time() + if not vlc.input.is_playing() then + return nil + end + return vlc.var.get(vlc.object.input(), "time") / 1000000 +end + + +------------------------------------------------------------------------------ +-- Gets the current play rate +------------------------------------------------------------------------------ +local +function get_play_rate() + if not vlc.input.is_playing() then + return nil + end + return vlc.var.get(vlc.object.input(), "rate") +end + + +------------------------------------------------------------------------------ +-- +------------------------------------------------------------------------------ +local +function complete_cache_data(key, max_try_obj) + local loc_cache_changed = false + + -- If we don't have the imdb information, go get it + if not cache[key].imdb and ( + not max_try_obj or + not max_try_obj.num or + not max_try_obj.last or ( + max_try_obj.num < trakt.config.media.info.max_try and + vlc.misc.mdate() >= ( + max_try_obj.last + + max_try_obj.num * + trakt.config.media.info.try_delay_factor * + 1000000 + ) + ) + ) then + -- Check that we have all required information before making the + -- request + if not cache[key].meta or not cache[key].duration then + vlc.msg.err('Missing information for media ' .. key .. + ' in order to get the main IMDB information') + return + end + + local args = { + '--quiet', + 'resolve', + '--meta', + json.encode(cache[key].meta), + '--duration', + tostring(cache[key].duration), + } + if cache[key].hash and cache[key].size then + table.insert(args, '--hash') + table.insert(args, cache[key].hash) + table.insert(args, '--size') + table.insert(args, tostring(cache[key].size)) + end + + local result, err = call_helper(args) + if result and next(result) ~= nil then + -- Clean-up results to keep only what's needed + for k, v in pairs(result) do + result[k] = { + ['base'] = v.base, + } + end + + -- And save the information in the cache + cache[key].imdb = result + cache_changed = true + loc_cache_changed = true + if max_try_obj then + max_try_obj.num = 0 + max_try_obj.last = -1 + end + elseif max_try_obj then + if not max_try_obj.num then + max_try_obj.num = 0 + end + max_try_obj.num = max_try_obj.num + 1 + max_try_obj.last = vlc.misc.mdate() + end + end + + if cache[key].imdb then + if not cache[key].imdb_details then + -- Check that we have all required information before making the + -- request + if not cache[key].duration then + vlc.msg.err('Missing information for media ' .. key .. + ' in order to get the IMDB details') + return + end + + -- Compute the total duration of the elements + local total = 0 + local missing_time = 0 + for k, v in pairs(cache[key].imdb) do + if v.base.runningTimeInMinutes then + total = total + v.base.runningTimeInMinutes * 60. + else + missing_time = missing_time + 1 + end + end + + -- Compute the factor between our media duration information + -- and the total time returned by imdb + local missing_time_duration + if missing_time > 0 then + if total >= cache[key].duration then + vlc.msg.err('(get_current_info) cannot determine duration ' .. + 'for some episodes') + return infos -- Stop now... + end + missing_time_duration = ( + cache[key].duration - total) / missing_time + total = cache[key].duration + end + cache[key].imdb_details = { + ['play_total'] = total, + ['play_factor'] = total / cache[key].duration, + ['missing_time_duration'] = missing_time_duration, + } + cache_changed = true + loc_cache_changed = true + end + + if not cache[key].imdb_details.per_media then + -- Get needed variables locally for performance + local missing_time_duration = cache[key].imdb_details.missing_time_duration + local play_factor = cache[key].imdb_details.play_factor + local play_total = cache[key].imdb_details.play_total + -- Prepare to save the information per media + local per_media = {} + local cur = 0 + for k, v in pairs(cache[key].imdb) do + local this_media = {} + if v.base.runningTimeInMinutes then + this_media['duration'] = v.base.runningTimeInMinutes * 60. + else + this_media['duration'] = missing_time_duration + end + this_media['vlcduration'] = this_media['duration'] / play_factor + + -- Compute the media time + this_media['from'] = { + ['vlctime'] = cur / play_factor, + ['time'] = cur, + ['percent'] = cur / play_total, + } + cur = cur + this_media['duration'] + this_media['to'] = { + ['vlctime'] = cur / play_factor, + ['time'] = cur, + ['percent'] = cur / play_total, + } + + -- Compute the media name + if v.base.titleType == 'tvEpisode' then + this_media['name'] = string.format( + '%s (%d) - S%02dE%02d - %s', + v.base.parentTitle.title, + v.base.parentTitle.year, + v.base.season, + v.base.episode, + v.base.title) + this_media['type'] = 'episode' + else + this_media['name'] = string.format( + '%s (%d)', + v.base.title, + v.base.year) + this_media['type'] = 'movie' + end + + -- Add to the list of media + table.insert(per_media, this_media) + end + + -- Add to the cache + cache[key].imdb_details.per_media = per_media + cache_changed = true + loc_cache_changed = true + end + end + + return loc_cache_changed +end + + +------------------------------------------------------------------------------ +-- Gets the information for the currently playing item in VLC. and returns it +-- in the form of a table to make it usable +------------------------------------------------------------------------------ +local +function get_current_info() + local loc_cache_changed = false + infos = {} + + repeat + item = vlc.input.item() + until (item and item:is_preparsed()) + repeat + until item:stats()["demux_read_bytes"] > 0 + + infos['name'] = item:name() + infos['uri'] = vlc.strings.decode_uri(item:uri()) + infos['duration'] = item:duration() + infos['meta'] = item:metas() + -- infos['info'] = item:info() + infos['play'] = { + ['global'] = get_play_time(), + } + infos['ratio'] = { + ['global'] = infos['play']['global'] / infos['duration'], + } + infos['time'] = vlc.misc.mdate() + + infos['key'] = infos['uri'] .. '#' .. infos['duration'] + + -- Check if the media is already in the cache + if not cache[infos['key']] then + cache[infos['key']] = {} + end + + if not cache[infos['key']].duration or + infos['duration'] ~= cache[infos['key']].duration then + cache[infos['key']].duration = infos['duration'] + cache_changed = true + loc_cache_changed = true + end + + if not cache[infos['key']].meta or + not equals(infos['meta'], cache[infos['key']].meta) then + cache[infos['key']].meta = infos['meta'] + cache_changed = true + loc_cache_changed = true + end + + if not cache[infos['key']].uri_proto then + -- Check if it's a local file or a distant URL... as for a local + -- file we can compute a hash for the file information resolution + local uri_proto, uri_path = string.match( + infos['uri'], '^([a-zA-Z0-9-]+)://(.*)$') + if not uri_proto and not uri_path then + uri_proto = 'file' + uri_path = infos['uri'] + end + + cache[infos['key']].uri_proto = uri_proto + cache[infos['key']].uri_path = uri_path + cache_changed = true + loc_cache_changed = true + end + + if cache[infos['key']].uri_proto == 'file' and + (not cache[infos['key']].hash or + not cache[infos['key']].size) then + -- Compute the media hash and size + local media_hash, media_size = movieHash(cache[infos['key']].uri_path) + + -- Check if any file in the cache matches those information + for k,v in pairs(cache) do + if v.hash == media_hash or v.size == media_size then + -- Copy the recently computed information in the cache entry + -- we're about to replace + cache[k].meta = cache[infos['key']].meta + cache[k].duration = cache[infos['key']].duration + cache[k].uri_proto = cache[infos['key']].uri_proto + cache[k].uri_path = cache[infos['key']].uri_path + + -- Then move that cache entry + cache[infos['key']] = cache[k] + cache[k] = nil + cache_changed = true + loc_cache_changed = true + break + end + end + + if not cache[infos['key']].hash then + cache[infos['key']].hash = media_hash + cache_changed = true + loc_cache_changed = true + end + if not cache[infos['key']].size then + cache[infos['key']].size = media_size + cache_changed = true + loc_cache_changed = true + end + end + + local max_try_obj + if watching and not watching.get_imdb_try then + watching['get_imdb_try'] = {} + max_try_obj = watching.get_imdb_try + end + if complete_cache_data(infos['key'], max_try_obj) then + loc_cache_changed = true + end + + -- Update the cache with the last usage data + date = call_helper({'date', '--format', '%s.%f'}) + if date and date.date then + cache[infos['key']]['last_use'] = tonumber(date.date) + else + vlc.msg.err('(get_current_info) Unable to update the last use ' .. + 'date for cache data ' .. infos['key']) + end + -- Only force saving the cache now if the cache has changed, else + -- the cache will be saved if the delay has passed + if loc_cache_changed then + vlc.msg.info('Force saving cache') + end + save_cache(cache, loc_cache_changed) + + -- Get the information from the cache for the current media + if cache[infos['key']].imdb then + -- Get needed variables locally for performance + local play_factor = cache[infos['key']].imdb_details.play_factor + local play_time_imdb = infos['play']['global'] * play_factor + -- Search the current media + for k, v in pairs(cache[infos['key']].imdb_details.per_media) do + if v['from'].percent <= infos['ratio'].global and + v['to'].percent >= infos['ratio'].global then + infos['local_idx'] = k + infos['orig_name'] = infos['name'] + infos['proper_name'] = v['name'] + infos['name'] = v['name'] + infos['type'] = v['type'] + infos['imdb'] = cache[infos['key']].imdb[k].base + infos['ratio']['local'] = (play_time_imdb - v['from'].time) / v['duration'] + infos['play']['local'] = play_time_imdb + infos['from'] = v['from'] + infos['to'] = v['to'] + break + end + end + end + + return infos +end + + +------------------------------------------------------------------------------ +-- +------------------------------------------------------------------------------ +local +function update_watching(media, status) + local status_changed + + -- If the media has changed + if not watching or media['key'] ~= watching['key'] then + -- Create the new watching object for the new media + watching = { + ['key'] = media['key'], + ['status'] = status, + ['trakt'] = { + ['watching'] = false, + ['scrobbled'] = false, + }, + ['current'] = { + ['from'] = media['play']['global'], + ['to'] = media['play']['global'], + }, + } + + status_changed = true + + -- Or if it hasn't changed and we've moved forward in it + else + -- Update the status to the status passed as parameter + if watching['status'] ~= status then + watching['status'] = status + status_changed = true + end + + -- Update the current play and ratio position + media_time_diff = media['play']['global'] - watching['play']['global'] + media_time_diff = media_time_diff / get_play_rate() + intf_time_diff = (media['time'] - watching['last_time']) / 1000000. + + -- Check if there was a jump in time, if it's the case, close the + -- current time set and open a new one + if media_time_diff < 0 or (media_time_diff / intf_time_diff) > 1.3 then + watching['current']['to'] = watching['play']['global'] + table.insert(cache[media['key']].watched, + watching['current']) + cache[media['key']].watched = merge_intervals( + cache[media['key']].watched) + cache_changed = true + watching['current'] = nil + -- Reset the watching status as we jumped + watching['trakt']['watching'] = false + end + + if watching['current'] then + watching['current']['to'] = media['play']['global'] + elseif status == 'playing' then + watching['current'] = { + ['from'] = media['play']['global'], + ['to'] = media['play']['global'], + } + end + end + + -- Update the watching status + watching['last_time'] = media['time'] + watching['play'] = media['play'] + watching['ratio'] = media['ratio'] + + -- No need to do what is after if we are paused and it is not + -- the first loop being paused + if status == 'paused' and not status_changed then + return + end + + -- Insure that the cache can receive the watched information + if not cache[media['key']].watched then + cache[media['key']].watched = {} + end + + -- Bring the intervals locally and merge with the current one + local temp = cache[media['key']].watched + if watching.current then + table.insert(temp, { + ['from'] = watching['current']['from'], + ['to'] = watching['current']['to'], + }) + end + temp = merge_intervals(temp) + + -- Then save it in the cache + cache[media['key']].watched = temp + cache_changed = true + + -- Compute the watched ratio and play for the media currently being + -- watched, from the information that we can read from the cache if + -- available; if it's not the case, we'll fall back on computing a + -- global watched ratio and play for the whole file. + local n = 1 + local sum = 0 + local vlcduration + if cache[media['key']].imdb_details and + cache[media['key']].imdb_details.per_media then + local v = cache[media['key']].imdb_details.per_media[ + media['local_idx']] + while temp[n] ~= nil and temp[n]['to'] <= v['from'].vlctime do + n = n + 1 + end + while temp[n] ~= nil do + if temp[n]['from'] >= v['to'].vlctime then + break + end + + local add_from = math.max(temp[n]['from'], v['from'].vlctime) + local add_to = math.min(temp[n]['to'], v['to'].vlctime) + sum = sum + (add_to - add_from) + + if temp[n]['to'] <= v['to'].vlctime then + n = n + 1 + else + break + end + end + vlcduration = v['vlcduration'] + else + while temp[n] ~= nil do + sum = sum + (temp[n]['to'] - temp[n]['from']) + n = n + 1 + end + vlcduration = media['duration'] + end + watching.play.watched = sum + watching.ratio.watched = sum / vlcduration +end + + +------------------------------------------------------------------------------ +-- Function being run when the media is currently playing +-- @param media The information about the media +------------------------------------------------------------------------------ +local +function media_is_playing(media) + vlc.msg.info(string.format('%s is playing! :) (%f/%f)', + media['name'], + media['play']['global'], + media['duration'])) + + update_watching(media, 'playing') + + -- If we keep the watching status in sync + if media['type'] and trakt.config.media.start[media['type']] then + -- Check if we need to scrobble start watching this media on trakt + if not watching.trakt.watching and media['imdb'] and + media['play']['local'] and media['ratio']['local'] and + media['play']['local'] >= trakt.config.media.start.time and + media['ratio']['local'] * 100. >= trakt.config.media.start.percent then + -- We need to save the index of the media to know which one we + -- have already set in watching status + local is_watching = trakt.scrobble('start', media) + if is_watching then + watching.trakt.watching = true + end + end + end + + -- If we want to scrobble + local might_scrobble = false + if media['type'] then + might_scrobble = trakt.config.media.stop[media['type']] + else + might_scrobble = (trakt.config.media.stop['episode'] or + trakt.config.media.stop['movie']) + end + if might_scrobble then + -- Determine the current scope: if we do not have imdb information, + -- it means that we cannot work in local scope as we cannot know + -- the per-media information, we thus need to consider the global + -- scope of the media file. This global scope will then be + -- converted when possible to the local scope(s). + local scope + local should_scrobble = false + if not watching.trakt.scrobbled then + if media['ratio']['local'] then + scope = 'local' + else + scope = 'global' + end + + -- Check, using the current scope, that we can actually + -- scrobble, following the configuration + if media['ratio'][scope] * 100. >= trakt.config.media.stop.percent and + media.ratio.watched * 100. >= trakt.config.media.stop.watched_percent then + should_scrobble = true + end + end + + if should_scrobble then + local idx + if scope == 'global' then + idx = 'all' + else + idx = media['local_idx'] + end + + -- That way, we won't scrobble the same media two times in a row + watching.trakt.scrobbled = true + + -- Get the date, we'll get it directly in the two formats we'll + -- need here, one for checking the delay, the other for storing + -- the information in the cache in case we need it + local date = call_helper({'date', '--format', '%s.%f', + '--format', '%Y-%m-%dT%H:%M:%S.%fZ'}) + if not date or not date[1] then + vlc.msg.err('(media_is_playing) Unable to get the current date') + return + end + + -- Check if we can scrobble... or if we're before the delay, in which + -- case we'll skip that scrobble + local ts = tonumber(date[1].date) + local skip_scrobble = false + local m_cache = cache[media['key']] + if m_cache.last_scrobble then + -- If we are in local scope, check if we have a last scrobble + -- information that was registered in global scope, in which + -- case, we need to convert it to local scope now that we + -- have the full information + if scope == 'local' and m_cache.last_scrobble.all then + for kidx, _ in pairs(m_cache.imdb_details.per_media) do + m_cache.last_scrobble[kidx] = math.max( + m_cache.last_scrobble[kidx], + m_cache.last_scrobble.all) + end + m_cache.last_scrobble.all = nil + end + if m_cache.last_scrobble[idx] and + (m_cache.last_scrobble[idx] + + trakt.config.media.stop.delay) >= ts then + skip_scrobble = true + end + end + + -- If we do not skip the scrobble, act on it! + if not skip_scrobble then + -- Save the fact that we need to scrobble in the cache... in case + -- anything happens! We do that before as it allows to scrobble + -- next time if VLC dies or is killed during the scrobble with + -- trakt! We don't want to lose any scrobble! + local scrobble_ready = { + ['idx'] = idx, + ['when'] = date[2].date, + } + + if not m_cache.last_scrobble then + m_cache.last_scrobble = {} + end + if not m_cache.scrobble_ready then + m_cache.scrobble_ready = {} + end + m_cache.last_scrobble[idx] = ts + table.insert(m_cache.scrobble_ready, scrobble_ready) + + -- Clean the 'watched' part of the cache that concerns this + -- episode; if we are not in episode scope, just clean everything + -- from the watched cache as everything should be (and will be) + -- scrobbled when we'll be able to + if scope == 'global' then + -- By resetting that table, the while for 'per-episode' work + -- will just stop before the first loop even starts + m_cache.watched = {} + end + + local i = 1 + while m_cache.watched[i] ~= nil do + local watched = m_cache.watched[i] + if watched['to'] <= media['from']['vlctime'] then + i = i + 1 + elseif watched['to'] <= media['to']['vlctime'] then + if watched['from'] <= media['from']['vlctime'] then + m_cache.watched[i]['to'] = media['from']['vlctime'] + else + table.remove(m_cache.watched, i) + end + else + if watched['from'] <= media['from']['vlctime'] then + local before = { + ['from'] = watched['from'], + ['to'] = media['from']['vlctime'], + } + local after = { + ['from'] = media['to']['vlctime'], + ['to'] = watched['to'], + } + m_cache.watched[i] = after + table.insert(m_cache.watched, i, before) + elseif watched['from'] <= media['to']['vlctime'] then + m_cache.watched[i]['from'] = media['to']['vlctime'] + end + break + end + end + + -- Save the cache + save_cache(cache, true) + + -- Only try to scrobble if we are in the local scope, as if + -- we are not, it means we are missing the imdb information + -- that is required to actually scrobble something + if scope == 'local' then + -- Then try to scrobble on trakt + -- Do a stop with a ratio that will absolutely scrobble + -- as watched; this allows for users to configure to + -- use lower ratios than Trakt.tv allows for + local is_scrobbled = trakt.scrobble('stop', media, 99.99) + -- Then reset the watch status to where we actually are + trakt.scrobble('start', media) + + -- If it worked, remove from the cache + if is_scrobbled then + table.remove(m_cache.scrobble_ready) + save_cache(cache, true) + end + end + end + end + end +end + + +------------------------------------------------------------------------------ +-- Function being run when the media is currently paused +-- @param media The information about the media +------------------------------------------------------------------------------ +local +function media_is_paused(media) + vlc.msg.info(media['name'] .. ' is paused! :| (' + .. get_play_time() .. '/' .. media['duration'] .. ')') + + update_watching(media, 'paused') + + -- Check if we need to pause scrobble + if watching.trakt.watching then + watching.trakt.watching = false + if media['imdb'] then + trakt.scrobble('pause', media) + end + end +end + + +------------------------------------------------------------------------------ +-- Function being run when the media has been stopped +-- @param media The information about the media +------------------------------------------------------------------------------ +local +function media_is_stopped(media) + vlc.msg.info(media['name'] .. ' is stopped! :(') + + -- If we were currently watching a media, we need to stop + if watching.trakt.watching then + --trakt.cancel_watching(media) + end + + -- Reset the watching information + watching = nil +end + + +------------------------------------------------------------------------------ +-- +------------------------------------------------------------------------------ +local +function process_scrobble_ready() + local max_try_obj = {} + local medias = {} + + -- Prepare the list of medias to add to the history + for key, media in pairs(cache) do + local sr + if media['scrobble_ready'] then + sr = media['scrobble_ready'] + else + sr = {} + end + if next(sr) ~= nil then + -- Insure that we have all the data required for the media + complete_cache_data(key, max_try_obj) + + -- Update the media object in case anything changed + media = cache[key] + end + if media.imdb and next(sr) ~= nil then + -- Go through the loop a first time to remove entries that are + -- not valid (invalid index or 'should not be scrobbled' type), + -- and if there is entries for 'all', replace them by an entry + -- for each media that should be scrobbled + local index = 1 + local size = #sr + while index <= size do + while sr[index]['idx'] == 'all' do + for k, _ in pairs(media.imdb) do + size = size + 1 + sr[size] = { + ['idx'] = k, + ['when'] = sr[index]['when'], + } + end + sr[index] = sr[size] + sr[size] = nil + size = size - 1 + cache_changed = true + end + if not sr[index] or + not media.imdb[sr[index].idx] or + not trakt.config.media.stop[ + media.imdb_details.per_media[ + sr[index].idx]['type']] then + sr[index] = sr[size] + sr[size] = nil + size = size - 1 + cache_changed = true + else + index = index + 1 + end + end + -- Create entries to be sync-ed + for k, v in pairs(sr) do + table.insert(medias, { + ['watched_at'] = v.when, + ['imdbid'] = string.sub(media.imdb[v.idx].base.id, 8, -2), + ['tvdbid'] = media.imdb[v.idx].base.tvdbid, + ['tmdbid'] = media.imdb[v.idx].base.tmdbid, + ['type'] = media.imdb_details.per_media[v.idx].type, + ['cache'] = { + ['key'] = key, + ['idx'] = v.idx, + } + }) + end + end + end + + -- Stop there if nothing to do + if next(medias) == nil then + return + end + + -- Execute the command + local result = trakt.add_to_history(medias) + if not result then + return + end + + -- All the medias that we could not add should be kept for another try + -- next time, we'll thus remove them from the media list, and show an + -- error message about them; we will also try to get extra IDs for + -- these medias in case we did not find them before + if #medias > result.added.episodes + result.added.movies then + local not_found = {} + for k, v in pairs(result.not_found.movies) do + table.insert(not_found, v.ids.imdb) + end + for k, v in pairs(result.not_found.episodes) do + table.insert(not_found, v.ids.imdb) + end + + -- Prepare the command to search for extra ids + command = { + '--quiet', + 'extraids', + } + local need_extra_ids = {} + + for _, v in pairs(not_found) do + for k, m in pairs(medias) do + if m and m.imdbid == v then + vlc.msg.err('Media ' .. m.cache.key .. + ' (idx: ' .. m.cache.idx .. ')' .. + ' was not added to history') + + -- Add the media information to the command in order + -- to get extra ids, but only if necessary + if (m['type'] == 'episode' and not m.tvdbid) or + not m.tmdbid then + local imdbinfo = cache[m.cache.key].imdb[m.cache.idx].base + if m['type'] == 'episode' then + table.insert(command, '--episode') + table.insert(command, imdbinfo.parentTitle.title) + table.insert(command, tostring(imdbinfo.season)) + table.insert(command, tostring(imdbinfo.episode)) + table.insert(command, tostring(imdbinfo.parentTitle.year)) + else + table.insert(command, '--movie') + table.insert(command, imdbinfo.title) + table.insert(command, tostring(imdbinfo.year)) + end + + table.insert(need_extra_ids, m) + end + + medias[k] = nil + end + end + end + + -- Run the helper command if needed + if #command > 2 then + local extra_ids = call_helper(command) + if not extra_ids or next(extra_ids) == nil then + vlc.msg.dbg('No extra_ids found at all... which is weird') + return + end + + local loc_cache_changed = false + + -- Then parse the extra ids found for each media + for _, m in pairs(need_extra_ids) do + local found_extra_ids; + local imdbinfo = cache[m.cache.key].imdb[m.cache.idx].base + if m['type'] == 'episode' then + found_extra_ids = extra_ids['episode'][ + imdbinfo.parentTitle.title][ + tostring(imdbinfo.season)][ + tostring(imdbinfo.episode)] + else + found_extra_ids = extra_ids['movie'][imdbinfo.title] + end + + -- If any of those ids was missing, add it, and flag the cache + -- to be saved + for idname, idvalue in pairs(found_extra_ids) do + idfield = idname .. 'id' + if not imdbinfo[idfield] then + imdbinfo[idfield] = idvalue + loc_cache_changed = true + end + end + end + + if loc_cache_changed then + -- If we found extra ids, force change cache + save_cache(cache, true) + end + end + end + + -- Prepare to remove the medias successfully added from the cache + local rm_scrobble_ready = {} + for k, v in pairs(medias) do + if not rm_scrobble_ready[v.cache.key] then + rm_scrobble_ready[v.cache.key] = {} + end + table.insert(rm_scrobble_ready[v.cache.key], v.cache.idx) + end + + -- Then remove them properly + for key, listidx in pairs(rm_scrobble_ready) do + table.sort(listidx, function(a, b) return a > b end) + for _, idx in pairs(listidx) do + table.remove(cache[key].scrobble_ready, idx) + end + + -- If there is no more to scrobble for this media + if next(cache[key].scrobble_ready) == nil then + cache[key].scrobble_ready = nil + end + end +end + + +------------------------------------------------------------------------------ +-- Function being run to determine the media status +------------------------------------------------------------------------------ +local +function determine_media_status() + ran_timers = timers.run() + vlc.msg.dbg('Timers ran: ' .. dump(ran_timers)) + + if vlc.input.is_playing() then + -- Get the information on the media being currently played + local infos = get_current_info() + + -- If the media being played is not the same as during the + -- previous loop, we have stuff to do + if last_infos and not equals(last_infos.imdb, infos.imdb) then + media_is_stopped(last_infos) + end + last_infos = infos + + -- We're going to run a different function if the media is + -- currently playing or paused + if vlc.playlist.status() == 'paused' then + media_is_paused(infos) + return + end + + -- If we reach here, it's that the media is currently playing + media_is_playing(infos) + + elseif last_infos then + -- If we reach here, it's that the media has been stopped, and + -- we still had its information, so we can run the function to + -- terminate properly all that's related to the media + media_is_stopped(last_infos) + last_infos = nil + end +end + + +------------------------------------------------------------------------------ +-- Function to check if there is any update available for TraktForVLC +------------------------------------------------------------------------------ +local +function check_update(filepath, install_output) + command = { + 'update', + '--vlc-lua-directory', ospath.dirname(path_to_helper), + '--vlc-config', vlc.config.configdir(), + '--yes', + } + if install_output then + table.insert(command, 1, '--loglevel') + table.insert(command, 2, 'INFO') + end + if filepath then + table.insert(command, '--file') + table.insert(command, filepath) + else + table.insert(command, '--release-type') + table.insert(command, trakt.config.helper.update.release_type) + end + if trakt.config.helper.mode == 'service' then + table.insert(command, '--service') + table.insert(command, '--service-host') + table.insert(command, tostring(trakt.config.helper.service.host)) + table.insert(command, '--service-port') + table.insert(command, tostring(trakt.config.helper.service.port)) + end + if trakt.config.helper.update.action == 'install' then + table.insert(command, '--install') + if install_output then + table.insert(command, '--install-output') + table.insert(command, install_output) + else + table.insert(command, '--discard-install-output') + end + elseif trakt.config.helper.update.action == 'download' then + table.insert(command, '--download') + end + local update = call_helper(command) + if not update or next(update) == nil then + vlc.msg.info('No update found for TraktForVLC.') + return + end + + if update.version then + vlc.msg.info('Found TraktForVLC version ' .. update.version) + else + update.version = 'unknown' + end + + if update.downloaded then + vlc.msg.info('TraktForVLC version ' .. + update.version .. ' has been downloaded') + end + + if update.installing then + vlc.msg.info('TraktForVLC version ' .. + update.version .. ' is being installed ' .. + '(will work after VLC restart)') + end + + return true +end + +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ +-- MAIN OF THE INTERFACE -- +------------------------------------------------------------------------------ +------------------------------------------------------------------------------ + +-- Print information about the interface when starting up +vlc.msg.info('TraktForVLC ' .. __version__ .. ' - Lua implementation') + +-- Check that local configuration parameters are authorized +local bad_config = false +for k, v in pairs(config) do + if k ~= 'autostart' and + k ~= 'check_update' and + k ~= 'init_auth' then + vlc.msg.error('Configuration option ' .. tostring(k) .. + 'is not recognized.') + bad_config = true + end +end +if bad_config then + vlc.msg.error('Quitting VLC.') + vlc.misc.quit() +end + +-- Locate the helper +if not path_to_helper then + path_to_helper = get_helper() +end +if not path_to_helper then + local func + if config.autostart then + func = vlc.msg.err + else + func = error + end + func('Unable to find the trakt helper, have you installed ' .. + 'TraktForVLC properly? - You can use the TRAKT_HELPER ' .. + 'environment variable to specify the location of the ' .. + 'helper') +else + vlc.msg.info('helper: ' .. path_to_helper) +end + +if config.autostart then + if config.autostart == 'enable' then + -- Enable 'lua' as extra VLC interface + local current_extraintf = vlc.config.get('extraintf') + local found_lua_intf = false + if current_extraintf then + for intf in string.gmatch(current_extraintf, "([^:]*)") do + if intf == 'luaintf' then + found_lua_intf = true + break + end + end + end + if not found_lua_intf then + if current_extraintf then + current_extraintf = current_extraintf .. ':luaintf' + else + current_extraintf = 'luaintf' + end + vlc.config.set('extraintf', current_extraintf) + vlc.msg.info('Lua interface enabled') + else + vlc.msg.info('Lua interface already enabled') + end + + -- Set the lua interface as being 'trakt' + local current_lua_intf = vlc.config.get('lua-intf') + if current_lua_intf ~= 'trakt' then + vlc.config.set('lua-intf', 'trakt') + vlc.msg.info('trakt Lua interface enabled') + else + vlc.msg.info('trakt Lua interface already enabled') + end + + vlc.msg.info('VLC is configured to automatically use TraktForVLC') + elseif config.autostart == 'disable' then + local current_extraintf = vlc.config.get('extraintf') + local found_lua_intf = false + local all_other_intf = {} + if current_extraintf then + for intf in string.gmatch(current_extraintf, "([^:]*)") do + if intf == 'luaintf' then + found_lua_intf = true + else + table.insert(all_other_intf, intf) + end + end + end + if found_lua_intf then + vlc.config.set('extraintf', table.concat(all_other_intf, ':')) + vlc.msg.info('Lua interface disabled') + else + vlc.msg.info('Lua interface already disabled') + end + + vlc.msg.info('VLC is configured not to use TraktForVLC') + else + vlc.msg.err('Unsupported value defined for autostart; must ' .. + 'be one of \'enable\' or \'disable\'') + end + vlc.misc.quit() +elseif config.check_update then + if not config.check_update.file then + vlc.msg.err('You forgot to specify the file to use for the update test') + else + vlc.msg.info('Will run check_update with parameter: ' .. config.check_update.file) + if config.check_update.output then + vlc.msg.info('Output will be written to ' .. config.check_update.output) + else + vlc.msg.info('Output will be discarded') + end + worked = check_update(config.check_update.file, config.check_update.output) + if not worked then + vlc.msg.err('Error while trying to update!') + else + vlc.msg.info('Sleeping ' .. tostring(config.check_update.wait) .. + ' seconds to emulate the fact that VLC is using the files...') + local i = config.check_update.wait + while i > 0 do + sleep(1) + i = i - 1 + if i % 10 == 0 then + vlc.msg.info(tostring(i) .. ' seconds left...') + end + end + end + vlc.msg.info('Exiting.') + end + vlc.misc.quit() +elseif config.init_auth then + -- Delete current configuration, as we are going to regenerate it + trakt.configured = false + trakt.config.auth = {} + save_config(trakt.config) + + -- Start the process to authenticate TraktForVLC with Trakt.tv + trakt.device_code() + + -- Exit VLC when finished + vlc.misc.quit() +else + -- If TraktForVLC is not yet configured with Trakt.tv, launch the device code + -- authentication process + if not trakt.configured then + trakt.device_code() + end + + -- Register timers + timers.register(process_scrobble_ready, ( + trakt.config.media.stop.check_unprocessed_delay * 1000000.)) + timers.register(cleanup_cache, ( + trakt.config.cache.delay.cleanup * 1000000.)) + if trakt.config.helper.update.check_delay ~= 0 and + __version__ ~= '0.0.0a0.dev0' then + timers.register(check_update, ( + trakt.config.helper.update.check_delay * 1000000.)) + end + + -- Main loop + while trakt.configured do + determine_media_status() + sleep(1) + end +end + +vlc.msg.info('TraktForVLC shutting down.') diff --git a/trakt_helper.py b/trakt_helper.py new file mode 100755 index 0000000..7f814bc --- /dev/null +++ b/trakt_helper.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# TraktForVLC, to link VLC watching to trakt.tv updating +# +# Copyright (C) 2017-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +# +# The aim of this file is to provide a helper for any task that cannot +# be performed easily in the lua interface for VLC. The lua interface +# will thus be able to call this tool to perform those tasks and return +# the results +# + +from __future__ import print_function +import logging +import platform +import sys + +from helper.version import * # noqa: F401, F403 +from helper.parser import ( + parse_args, +) + +LOGGER = logging.getLogger(__name__) + + +############################################################################## +# Main method that will parse the command line arguments and run the function +# to perform the appropriate actions +def main(): + # If no command line arguments, defaults to installation + if len(sys.argv) == 1: + if platform.system() == 'Windows': + sys.argv.append('--keep-alive') + sys.argv.append('install') + + args, action, params = parse_args() + + ########################################################################## + # Prepare the logger + if args.loglevel == 'DEFAULT': + if args.command in ['install', 'uninstall', 'service']: + args.loglevel = 'INFO' + else: + args.loglevel = 'WARNING' + + log_level_value = getattr(logging, args.loglevel) + logargs = { + 'level': log_level_value, + 'format': args.logformat, + } + if args.logfile: + logargs['filename'] = args.logfile + logging.basicConfig(**logargs) + + ########################################################################## + # Call the function + try: + exit_code = action(**params) + except Exception as e: + LOGGER.exception(e, exc_info=True) + raise + + if exit_code is None: + exit_code = 0 + + if hasattr(args, 'keep_alive') and args.keep_alive: + print('Press a key to continue.') + raw_input() + + sys.exit(exit_code) + + +if __name__ == '__main__': + main() diff --git a/version.py b/version.py new file mode 100755 index 0000000..6785263 --- /dev/null +++ b/version.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# To read the git and Continuous Integration versions information, +# and easily set those versions in the project files when building +# +# Copyright (C) 2016-2018 Raphaël Beamonte +# +# This file is part of TraktForVLC. TraktForVLC is free software: +# you can redistribute it and/or modify it under the terms of the GNU +# General Public License as published by the Free Software Foundation, +# version 2. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# or see . + +# +# The aim of this file is to provide a helper for any task that cannot +# be performed easily in the lua interface for VLC. The lua interface +# will thus be able to call this tool to perform those tasks and return +# the results +# + +from __future__ import print_function +import argparse +import logging +import os +import platform +import re +import shutil +import subprocess +import tempfile +import time + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class GitVersionException(Exception): + pass + + +class GitVersionReader(object): + + pep440_public_version_pattern = ( + "(?:(?P[0-9]*)!)?" + "(?P[0-9]*(\.[0-9]*)*)" + "((?Pa|b|rc)(?P[0-9]*))?" + "(\.post(?P[0-9]*))?" + "(\.dev(?P[0-9]*))?") + pep440_local_version_pattern = ( + "{}" + "(?:\+(?P[a-zA-Z0-9]+" + "(?:\.[a-zA-Z0-9]*)*))?").format(pep440_public_version_pattern) + + pep440_public_version = re.compile( + "^v?(?P{})$".format(pep440_public_version_pattern)) + pep440_local_version = re.compile( + "^v?(?P{})$".format(pep440_local_version_pattern)) + + def __init__(self, path=os.path.dirname(__file__), + match=False, tags=False): + self._path = path + self._match = match + self._tags = tags + + def call_git_describe(self, abbrev=7, exact=False, always=False, + match=False, tags=False, commit=None, dirty=True): + cmd = ['git', 'describe', '--abbrev={}'.format(abbrev)] + if tags: + cmd.append('--tags') + if match: + cmd.append(['--match', str(match)]) + if exact: + cmd.append('--exact-match') + if always: + cmd.append('--always') + if commit: + cmd.append(commit) + elif dirty: + cmd.append('--dirty') + + try: + version = subprocess.check_output( + cmd, + stderr=subprocess.STDOUT, + cwd=self._path, + ) + return version.strip() + except subprocess.CalledProcessError as e: + if '.gitconfig' in e.output: + raise GitVersionException( + 'You may need to update your git version: {}'.format( + e.output)) + except OSError as e: + if e.strerror != 'No such file or directory': + raise + + # Version not found, but no error raised + return None + + def call_git_rev_list(self, branch='HEAD'): + cmd = ['git', 'rev-list', '--count', branch] + + try: + count = subprocess.check_output( + cmd, + stderr=subprocess.STDOUT, + cwd=self._path, + ) + return int(count.strip()) + except subprocess.CalledProcessError as e: + if '.gitconfig' in e.output: + raise GitVersionException( + 'You may need to update your git version: {}'.format( + e.output)) + except OSError as e: + if e.strerror != 'No such file or directory': + raise + + # We were not able to get the rev list, but no error raised + return 0 + + def get_version(self, abbrev=7, match=None, tags=None): + dev = False + + if match is None: + match = self._match + if tags is None: + tags = self._tags + + # Search first for a tag + version = self.call_git_describe(abbrev, exact=True, match=match, + tags=tags) + if not version: + # The current version is not tagged, so it is a dev version + dev = True + + # Try to find the version computed from the closest tag + version = self.call_git_describe(abbrev, match=match, tags=tags) + if not version: + # We did not find any tag matching, so maybe there is no tag + # in the repo yet? We will try to find the commit number to + # define the version + commit = self.call_git_describe(abbrev, always=True, + match=match, tags=tags) + if not commit: + raise GitVersionException( + 'Unable to find the version number') + + # If we found the commit, it's the one defining the version + version = '0.0.0a0-{}-g{}'.format(self.call_git_rev_list(), + commit) + + if dev or version.endswith('-dirty'): + vsplit = version.split('-') + + if dev: + # Check if there is already a dev number in the tag + m = self.pep440_public_version.search(vsplit[0]) + if m and m.group('dev_num'): + vsplit[1] = int(vsplit[1]) + int(m.group('dev_num')) + vsplit[0] = re.sub('\.dev{}$'.format(m.group('dev_num')), + '', vsplit[0]) + + vsplit[0] = '{}.dev{}'.format(vsplit[0], vsplit[1]) + + m = False if vsplit[0][0].isdigit() else re.search('\d', vsplit[0]) + version = '{}+{}'.format( + vsplit[0][m.start():] if m else vsplit[0], + '.'.join(vsplit[2 if dev else 1:])) + + return version + + +class CIVersionReader(GitVersionReader): + PULL_REQUEST_ENV = [ + 'TRAVIS_PULL_REQUEST', # For Travis + 'APPVEYOR_PULL_REQUEST_NUMBER', # For AppVeyor + ] + + def read_pullrequest_version(self): + for pr_env in self.PULL_REQUEST_ENV: + pr = os.getenv(pr_env) + if pr and pr.lower() != 'false': + break + + if not pr or pr.lower() == 'false': + return None + + m = re.search('\d+', pr) + return str(m.group(0) if m else pr).strip() + + def check_tag(self, tag=None, abbrev=7): + if not tag: + tag = self.call_git_describe(abbrev, exact=True) + + # If we did not find any version, we can return now, there + # is no tag to check + if not tag: + raise GitVersionException( + 'No tag found for the current commit.') + + if not self.pep440_public_version.search(tag): + raise GitVersionException( + 'Version {} does not match PEP440 for public version ' + 'identifiers.'.format(tag)) + + def get_version(self, *args, **kwargs): + try: + version = super(CIVersionReader, self).get_version( + *args, **kwargs) + except GitVersionException as e: + logger.warning(e) + version = '0.0.0a0.dev0+unknown' + + pr = self.read_pullrequest_version() + if pr: + version = '{version}{sep}{pr}'.format( + version=version, + sep='.' if '+' in version else '+', + pr='pr{}'.format(pr), + ) + + return version + + def get_release_name(self, commit=None, always=False): + while 'I have not found a release name': + # Try and get the closest tag + try: + tag = self.call_git_describe(abbrev=0, dirty=False, + commit=commit) + except GitVersionException: + # No tag, there is no release name + return None + else: + if not tag: + return None + + # Get that tag's description + try: + tag_desc = subprocess.check_output( + ['git', 'tag', '-n', tag], + stderr=subprocess.STDOUT, + cwd=self._path, + ).strip() + except subprocess.CalledProcessError as e: + if '.gitconfig' in e.output: + raise GitVersionException( + 'You may need to update your git version: {}'.format( + e.output)) + except OSError as e: + if e.strerror != 'No such file or directory': + raise + + # Use a regular expression to check if this leads to a release name + relname_re = re.compile( + '^{}\s*(?:Prer|R)elease: (?P.*)$'.format( + re.escape(tag))) + m = relname_re.search(tag_desc) + if not m: + # This tag did not lead to a release name + if always: + # try with the commit before it! + commit = '{}^'.format(tag) + continue + else: + return None + + return m.group('relname') + + def get_environment(self, version, variables=None, check_previous=True, + asdict=False): + # Depending on the platform, we will output different format of + # environment variables to be loaded directly with an eval-like + # command + if platform.system() == 'Windows': + env_format = '$env:{key} = {value};' + true_format = '$true' + escaped_quote = '`"' + else: + env_format = 'export {key}={value}' + true_format = '1' + escaped_quote = '\\"' + + # First, match the version with the local version format of PEP440, + # if it does not match, we're having a problem. + m = self.pep440_local_version.search(version) + if not m: + raise GitVersionException( + 'Version {} does not match PEP440 for local version ' + 'identifiers.'.format(version)) + + # Prepare a variable to get the release name information + relname_commit = None + + # Get the results as a dictionary + values = m.groupdict() + + # Check if there is a local part, in which case we want to split it + # to have as many information as possible + if values['local']: + local = values['local'] + + # Check if there was a specific commit number + re_commit = re.compile("^g(?P[a-z0-9]+)(\.|$)") + m_commit = re_commit.search(local) + if m_commit: + values['local_commit'] = m_commit.group('commit') + relname_commit = m_commit.group('commit') + local = re_commit.sub('', local) + + # Check if the repository was considered as dirty + re_dirty = re.compile("(^|\.)dirty(\.|$)") + m_dirty = re_dirty.search(local) + if m_dirty: + values['local_dirty'] = True + local = re_dirty.sub('\g<1>', local) + + # Check if there was a PR being processed + re_pr = re.compile("(^|\.)pr(?P[0-9]+)(\.|$)") + m_pr = re_pr.search(local) + if m_pr: + values['local_pr'] = m_pr.group('pr') + local = re_pr.sub('\g<1>', local) + + # If there is still information, that we thus do not expect, raise + # an exception: the version is not in an expected format + if local and local != '.': + raise GitVersionException( + 'Version {} does not match the requirements for the ' + 'local version part: \'{}\' is left after parsing the ' + 'known information.'.format(version, local)) + + # If a commit information was found, check that commit's parent + # information + if check_previous and values['dev_num']: + same = False + + parent = self.call_git_describe( + commit=m_commit.group('commit')) + if parent: + sparent = parent.split('-') + m_parent = self.pep440_public_version.search( + sparent[0]) + + if m_parent: + vparent = m_parent.groupdict() + + same = True + for check in [ + 'epoch', 'release', 'pre_type', + 'pre_num', 'post_num']: + if vparent[check] != values[check]: + same = False + break + + if same and values['dev_num'] and vparent['dev_num'] and \ + (int(values['dev_num']) == + int(vparent['dev_num']) + int(sparent[1])): + values['parent_dev_num'] = vparent['dev_num'] + values['relative_dev_num'] = sparent[1] + + # Prepare the local-related information to be added in the + # description; In the current setup, the dev-num is directly + # linked to the number of git commit ahead of the given tag, + # which means that if there is a dev-num, there should always + # be local information + local_desc_info = [] + if m_pr: + local_desc_info.append('pull request {}'.format( + values['local_pr'])) + if values['dev_num']: + local_desc_info.append('{} commit{} ahead'.format( + values.get('relative_dev_num', values['dev_num']), + 's' if int(values['dev_num']) > 1 else '')) + if m_commit: + local_desc_info.append('commit {}'.format( + values['local_commit'])) + if m_dirty: + local_desc_info.append('dirty') + + local_desc = 'a development version ({}) based on '.format( + ', '.join(local_desc_info)) + else: + relname_commit = version + local_desc = '' + + # Prepare the dev-related information to be added in the description; + # we only compute that information if there is no local information, + # because in that case it means that we are actually using a git tag + # containing that information + if (values['dev_num'] and not values['local']) or \ + 'parent_dev_num' in values: + dev_num = values.get('parent_dev_num', values['dev_num']) + + # Ordinal function taken from Gareth on codegolf + def ordinal(n): + return "{}{}".format( + n, "tsnrhtdd"[(n / 10 % 10 != 1) * + (n % 10 < 4) * n % 10::4]) + + dev_desc = 'the {} development release of '.format( + ordinal(int(dev_num))) + else: + dev_desc = '' + + # Prepare the post-related information to be added in the description + if values['post_num']: + post_desc = 'the post-release {} of '.format( + values['post_num']) + else: + post_desc = '' + + # Prepare the description messages depending on the type of release + if values['pre_type']: + if values['pre_type'] == 'rc': + values['pre_type'] = 'release candidate' + compl_desc = ( + "New features will not be added to the " + "release {release}, only bugfixes.").format( + release=values['release']) + elif values['pre_type'] == 'b': + values['pre_type'] = 'beta' + compl_desc = ( + "This should not be considered stable and used with " + "precautions.") + else: + values['pre_type'] = 'alpha' + compl_desc = ( + "This should only be used if you know what you are " + "doing.") + + values['description'] = ( + "This is {local_desc}{dev_desc}{post_desc}the " + "{pre_type} {pre_num} of TraktForVLC {release}. " + "{compl_desc}").format( + local_desc=local_desc, dev_desc=dev_desc, + post_desc=post_desc, compl_desc=compl_desc, **values) + else: + values['description'] = ( + "This is {local_desc}{dev_desc}{post_desc}" + "TraktForVLC {release}.").format( + local_desc=local_desc, dev_desc=dev_desc, + post_desc=post_desc, **values) + + # Add the version name + values['name'] = 'TraktForVLC {}'.format(values['full']) + relname = self.get_release_name(commit=relname_commit) + if relname: + values['release_name'] = relname + values['name'] = '{} "{}"'.format( + values['name'], relname).replace('"', escaped_quote) + + if asdict: + return { + k: v for k, v in values.items() + if variables is None or k in variables + } + + # Prepare the output, for each entry in our dict, we will format one + # line of output as an environment variable + output = [] + for k, v in sorted(values.items()): + if v is None or (variables is not None and k not in variables): + continue + + if v is True: + v = true_format + else: + v = '"{}"'.format(v) + + k = 'TRAKT_VERSION' if k == 'full' else \ + 'TRAKT_VERSION_{}'.format(k.upper()) + + output.append(env_format.format(key=k, value=v)) + + # Join the output and return it + return '\n'.join(output) + + +def set_version(full, release_name=None, **env): + base = os.path.dirname(os.path.realpath(__file__)) + reset = (full == '0.0.0a0.dev0') + + rules = [ + { + 'files': [ + os.path.join(base, 'trakt.lua'), + ], + 'patterns': [ + ( + re.compile("^(local __version__ = )'.*'$", re.MULTILINE), + "\g<1>'{}'".format(full), + ), + ] + }, + { + 'files': [ + os.path.join(base, 'helper', 'version.py'), + ], + 'patterns': [ + ( + re.compile("^(__version__ = )'.*'$", re.MULTILINE), + "\g<1>'{}'".format(full), + ), + ( + re.compile("^(__release_name__ = )'.*'$", re.MULTILINE), + "\g<1>'{}'".format( + release_name + if release_name and not reset else ''), + ), + ( + re.compile("^(__build_date__ = )'.*'$", re.MULTILINE), + "\g<1>'{}'".format( + time.strftime("%a, %d %b %Y %H:%M:%S +0000", + time.gmtime()) + if not reset else ''), + ), + ( + re.compile("^(__build_system__ = )'.*'$", re.MULTILINE), + "\g<1>'{}'".format( + platform.system() + if not reset else ''), + ), + ], + }, + ] + + for rule in rules: + for f in rule['files']: + fd, tmp = tempfile.mkstemp() + shutil.copystat(f, tmp) + + try: + with open(f, 'r') as fin, os.fdopen(fd, 'w') as fout: + out = fin.read() + for p, r in rule['patterns']: + out = p.sub(r, out) + fout.write(out) + except Exception: + os.remove(tmp) + raise + else: + shutil.move(tmp, f) + + +def main(): + versionreader = CIVersionReader() + version = versionreader.get_version() + + parser = argparse.ArgumentParser( + description='Tool to read and set the version for the different ' + 'files of the project') + + commands = parser.add_subparsers( + help='Commands', + dest='command') + + commands.add_parser( + 'version', + help='Will print the computed version') + + set_parser = commands.add_parser( + 'set', + help='Will set the current version in the lua and py files') + set_parser_group = set_parser.add_mutually_exclusive_group() + set_parser_group.add_argument( + '--reset', + dest='version', + action='store_const', + const='0.0.0a0.dev0', + help='Reset version to 0.0.0a0.dev0') + set_parser_group.add_argument( + '--version', + help='Define the version to be set') + + check_tag_parser = commands.add_parser( + 'check-tag', + help='To check if the current tag is properly formatted for ' + 'releasing a version, according to PEP440') + check_tag_parser.add_argument( + '-t', '--tag', + help='The version tag to check. If not given, will look for the ' + 'tag set for the current version - and fail if not found') + + environment_parser = commands.add_parser( + 'environment', + help='Return the environment variables defining the version') + environment_parser.add_argument( + '--version', + help='Define the version to be parsed') + environment_parser.add_argument( + '--variable', + action='append', + help='Only return that environment variable; can be repeated') + + args = parser.parse_args() + if args.command == 'version': + print(version) + elif args.command in 'set': + set_version(**versionreader.get_environment( + args.version or version, asdict=True)) + print('OK') + elif args.command == 'check-tag': + versionreader.check_tag(tag=args.tag) + print('OK') + elif args.command == 'environment': + print(versionreader.get_environment( + args.version or version, args.variable)) + else: + raise Exception('Unknown command.') + + +if __name__ == '__main__': + main() diff --git a/vlc-logo-1s.mp4 b/vlc-logo-1s.mp4 new file mode 100644 index 0000000..c19d309 Binary files /dev/null and b/vlc-logo-1s.mp4 differ