├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── feature_request.yml │ └── help.yml └── workflows │ ├── contributors.yml │ ├── docker.yml │ ├── on_commit.yml │ ├── sponsors.yml │ └── upload_unraid_template.yml ├── .gitignore ├── .schema └── config_v2.schema.json ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api.py ├── api ├── __init__.py ├── controllers │ ├── __init__.py │ └── webhook_processor.py └── routes │ ├── index.py │ └── webhooks │ └── tautulli │ └── index.py ├── ci ├── requirements.txt └── validate_example_config.py ├── consts.py ├── docker-compose.yml ├── documentation ├── ANNOUNCEMENTS.md ├── DEVELOPMENT.md ├── DOCUMENTATION.md ├── images │ ├── banner.png │ ├── embed.png │ ├── graphs_play_duration_day_of_week.png │ ├── icon.png │ ├── libraries.png │ ├── logo.png │ ├── message_content_intent.png │ ├── most_active_libraries.png │ ├── permissions.png │ ├── recently_added.png │ ├── tauticord_webhook_config_1.png │ ├── tauticord_webhook_config_2.png │ └── tauticord_webhook_config_3.png └── upgrade_guides │ └── v4_to_v5_migration.md ├── ecosystem.config.json ├── entrypoint.sh ├── legacy ├── __init__.py ├── config_parser_v1.py ├── statics.py ├── text_manager.py └── utils.py ├── migrations ├── __init__.py ├── base.py ├── m001_env_var_to_config_yaml.py ├── m002_old_config_to_new_config.py ├── m003_add_recently_added_webhook.py ├── migration_manager.py └── migration_names.py ├── modules ├── __init__.py ├── analytics.py ├── charts.py ├── database │ ├── __init__.py │ ├── base │ │ ├── __init__.py │ │ ├── base.py │ │ ├── imports.py │ │ └── utils.py │ ├── database.py │ ├── migrations.py │ ├── models │ │ ├── __init__.py │ │ ├── recently_added_item.py │ │ ├── version.py │ │ └── webhooks.py │ └── repository.py ├── discord │ ├── __init__.py │ ├── bot.py │ ├── commands │ │ ├── __init__.py │ │ ├── _base.py │ │ ├── graphs.py │ │ ├── most.py │ │ ├── recently.py │ │ └── summary.py │ ├── discord_utils.py │ ├── models │ │ ├── __init__.py │ │ ├── tautulli_activity_summary.py │ │ └── tautulli_stream_info.py │ ├── services │ │ ├── __init__.py │ │ ├── base_service.py │ │ ├── library_stats.py │ │ ├── live_activity.py │ │ ├── performance_stats.py │ │ ├── slash_commands.py │ │ └── tagged_message.py │ └── views │ │ ├── __init__.py │ │ └── paginated_view.py ├── emojis.py ├── errors.py ├── logs.py ├── models │ ├── __init__.py │ ├── recently_added_item.py │ └── webhook.py ├── settings │ ├── __init__.py │ ├── config_parser.py │ └── models │ │ ├── __init__.py │ │ ├── anonymity.py │ │ ├── base.py │ │ ├── discord.py │ │ ├── display.py │ │ ├── extras.py │ │ ├── libraries.py │ │ ├── run_args.py │ │ ├── stats.py │ │ ├── tautulli.py │ │ ├── time.py │ │ ├── voice_category.py │ │ └── voice_channel.py ├── statics.py ├── system_stats.py ├── tasks │ ├── __init__.py │ ├── activity.py │ ├── library_stats.py │ ├── performance_stats.py │ └── voice_category_stats.py ├── tautulli │ ├── __init__.py │ ├── enums.py │ ├── models │ │ ├── __init__.py │ │ ├── activity.py │ │ ├── library_item_counts.py │ │ ├── recently_added_media_item.py │ │ ├── session.py │ │ └── stats.py │ └── tautulli_connector.py ├── text_manager.py ├── time_manager.py ├── utils.py ├── versioning.py └── webhooks │ ├── __init__.py │ └── tautulli_recently_added.py ├── pm2_keepalive.py ├── requirements.txt ├── resources └── emojis │ ├── 1.png │ ├── 10.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ ├── 5.png │ ├── 6.png │ ├── 7.png │ ├── 8.png │ ├── 9.png │ ├── buffering.png │ ├── episode.png │ ├── error.png │ ├── movie.png │ ├── paused.png │ ├── person.png │ ├── playing.png │ ├── stopped.png │ └── track.png ├── run.py ├── tauticord.yaml.example └── templates └── tauticord.xml /.dockerignore: -------------------------------------------------------------------------------- 1 | *.md 2 | LICENSE 3 | Dockerfile 4 | .dockerignore 5 | .gitignore 6 | .github 7 | .git 8 | .idea 9 | docker-compose.yml 10 | venv 11 | .venv 12 | __pycache__ 13 | Tauticord.log 14 | documentation 15 | templates 16 | on_host 17 | 00*.yaml 18 | .migration_* 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: A bug has been discovered in Tauticord's source code. 3 | title: "[BUG] - " 4 | labels: [ "bug", "triage" ] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for reporting a bug in Tauticord's source code. Please fill out the information below to help us investigate and resolve the issue. 10 | 11 | Please replace `YOUR_SUMMARY_HERE` in the title with a brief summary of the bug, and complete the information below. 12 | - type: checkboxes 13 | id: new_issue 14 | attributes: 15 | label: This bug has not been reported already 16 | description: | 17 | To avoid duplicate tickets, please verify that the bug has not already been reported. If it has, please add any additional information to the existing ticket. 18 | options: 19 | - label: I have verified that this bug has not already been reported. 20 | required: true 21 | - type: checkboxes 22 | id: have_tried_fixing_self 23 | attributes: 24 | label: You have tried to fix the bug yourself 25 | description: | 26 | If you have the technical knowledge to fix the bug yourself, please consider [submitting a pull request](https://github.com/nwithan8/tauticord/pulls) with the fix. If you are unable to fix the bug, please continue with the bug report. 27 | options: 28 | - label: I have tried to fix the bug myself. 29 | required: false 30 | - type: textarea 31 | id: current_behaviour 32 | attributes: 33 | label: Current Behavior 34 | description: | 35 | Please describe the behavior of the bug (i.e. what is currently happening due to the bug). 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: expected_behaviour 40 | attributes: 41 | label: Expected Behavior 42 | description: | 43 | Please describe the expected behavior of the bug (i.e. what should happen if the bug did not exist). 44 | validations: 45 | required: true 46 | - type: textarea 47 | id: recreation_steps 48 | attributes: 49 | label: Steps to Reproduce 50 | description: | 51 | Please provide detailed steps to reproduce the bug. Include any relevant information such as the operating system, browser, or other software being used. 52 | validations: 53 | required: true 54 | - type: textarea 55 | id: supporting_info 56 | attributes: 57 | label: Supporting Information 58 | description: | 59 | Please provide any additional information that may be helpful in diagnosing and resolving the bug, including error messages, logs, screenshots and lines of code. 60 | - type: markdown 61 | attributes: 62 | value: | 63 | Thank you for submitting your report. 64 | 65 | Please keep an eye on this issue for any updates. If you have any additional information to add, please comment on this issue or join our [Discord server](https://discord.gg/ygRDVE9) to discuss the issue further. 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: A request for a new feature in Tauticord. 3 | title: "[FEATURE] - " 4 | labels: [ "enhancement", "triage" ] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for opening a feature request for Tauticord. Please fill out the information below. 10 | 11 | Please replace `YOUR_SUMMARY_HERE` in the title with a brief summary of your idea, and complete the information below. 12 | - type: checkboxes 13 | id: new_request 14 | attributes: 15 | label: This request has not already been submitted 16 | description: | 17 | To avoid duplicate requests, please verify that no other user has already made a similar request. You can search for similar requests [here](https://github.com/nwithan8/tauticord/issues?q=is%3Aissue) or on our [Discord server](https://discord.gg/ygRDVE9). 18 | options: 19 | - label: I have verified that no one has asked for this feature before. 20 | required: true 21 | - type: checkboxes 22 | id: feature_is_appropriate 23 | attributes: 24 | label: The feature is appropriate for Tauticord 25 | description: | 26 | Before submitting a feature request, please ensure that the feature is appropriate for Tauticord and aligns with the project's goals. 27 | options: 28 | - label: I believe this feature is appropriate for Tauticord. 29 | required: true 30 | - type: dropdown 31 | id: feature_type 32 | attributes: 33 | label: Feature Type / Category 34 | description: | 35 | Please select the type or category of the feature you are requesting. 36 | options: 37 | - Statistic (e.g. New statistic or metric) 38 | - Command (e.g. Interactive command) 39 | - Integration (e.g. New service integration) 40 | - Other 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: feature_details 45 | attributes: 46 | label: Feature Details 47 | description: | 48 | Please describe in as much detail as possible the feature you are requesting. Please include any relevant information such as use cases, examples, or other details that may help us understand your request. 49 | 50 | If you have any ideas for how the feature should be implemented from a code perspective, please include those as well. Even better, consider [submitting a pull request](https://github.com/nwithan8/tauticord/pulls) with your changes if you are able. 51 | validations: 52 | required: true 53 | - type: markdown 54 | attributes: 55 | value: | 56 | Thank you for submitting your request. We will review the information provided and respond as soon as possible. 57 | 58 | Please keep an eye on this issue for any updates. If you have any additional information to add, please comment on this ticket or join our [Discord server](https://discord.gg/ygRDVE9). 59 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help.yml: -------------------------------------------------------------------------------- 1 | name: Help Request 2 | description: A request for help with using Tauticord. 3 | title: "[HELP] - " 4 | labels: [ "help", "triage" ] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for opening a help request for Tauticord. Please fill out the information below to help us investigate and resolve the issue. 10 | 11 | Please replace `YOUR_SUMMARY_HERE` in the title with a brief summary of your issue, and complete the information below. 12 | - type: checkboxes 13 | id: new_issue 14 | attributes: 15 | label: This issue has not already been resolved 16 | description: | 17 | To avoid duplicate requests, please verify that no other user has already made a similar request. You can search for similar issues [here](https://github.com/nwithan8/tauticord/issues?q=is%3Aissue) or on our [Discord server](https://discord.gg/ygRDVE9). 18 | options: 19 | - label: I have verified that no one has asked for help with this issue before. 20 | required: true 21 | - type: checkboxes 22 | id: reread_docs 23 | attributes: 24 | label: You have read the documentation 25 | description: | 26 | Before asking for help, please ensure that you have read the [documentation](https://github.com/nwithan8/tauticord/blob/master/README.mdhttps://github.com/nwithan8/tauticord/blob/master/README.md) and verified that your issue is not already addressed there. 27 | options: 28 | - label: I have read the documentation and my issue is not addressed there. 29 | required: true 30 | - type: dropdown 31 | id: installation_type 32 | attributes: 33 | label: Installation Type 34 | description: | 35 | Please select how you are running Tauticord. 36 | options: 37 | - Standalone Docker 38 | - Docker on Unraid 39 | - Docker Compose 40 | - Kubernetes 41 | - Python script (not recommended) 42 | - Other 43 | validations: 44 | required: true 45 | - type: dropdown 46 | id: configuration_type 47 | attributes: 48 | label: Configuration Type 49 | description: | 50 | Please select how you are configuring Tauticord. 51 | options: 52 | - Configuration File 53 | - Environment Variables (no longer supported) 54 | - Unknown 55 | validations: 56 | required: true 57 | - type: dropdown 58 | id: issue_type 59 | attributes: 60 | label: Type of Issue 61 | description: | 62 | Please select the type of issue you are experiencing. 63 | options: 64 | - Installation (e.g. Cannot install Tauticord) 65 | - Running (e.g. Tauticord not starting) 66 | - Configuration (e.g settings not being applied) 67 | - Usage (e.g. Tauticord not updating/responding) 68 | - Other 69 | validations: 70 | required: true 71 | - type: textarea 72 | id: issue_details 73 | attributes: 74 | label: Issue Details 75 | description: | 76 | Please describe in detail the issue you are facing. 77 | validations: 78 | required: true 79 | - type: textarea 80 | id: recreation_steps 81 | attributes: 82 | label: Steps to Reproduce 83 | description: | 84 | Please describe how to reproduce the issue you are facing. 85 | validations: 86 | required: true 87 | - type: textarea 88 | id: logs 89 | attributes: 90 | label: Logs 91 | description: | 92 | Please provide any relevant logs that may help diagnose the issue. 93 | 94 | **NOTE:** The log messages printed to the console are only informational and may not contain the necessary information to diagnose the issue. Please instead provide the logs from the `Tauticord.log` file in the `logs` directory. 95 | 96 | **Please remove any sensitive information before posting logs.** 97 | validations: 98 | required: true 99 | - type: textarea 100 | id: supporting_info 101 | attributes: 102 | label: Supporting Information 103 | description: | 104 | Please provide any additional information that may be helpful. 105 | validations: 106 | required: false 107 | - type: markdown 108 | attributes: 109 | value: | 110 | Thank you for submitting your request. We will review the information provided and respond as soon as possible. 111 | 112 | Please keep an eye on this issue for any updates. If you have any additional information to add, please comment on this ticket or join our [Discord server](https://discord.gg/ygRDVE9). 113 | -------------------------------------------------------------------------------- /.github/workflows/contributors.yml: -------------------------------------------------------------------------------- 1 | name: Update contributors 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | jobs: 8 | update_contributors: 9 | name: Add all contributors to README 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v2.4.0 14 | with: 15 | fetch-depth: '0' 16 | 17 | - name: Generate contributors list 18 | uses: BobAnkh/add-contributors@master 19 | with: 20 | CONTRIBUTOR: '### Contributors' 21 | COLUMN_PER_ROW: '4' 22 | ACCESS_TOKEN: ${{secrets.AC_PAT}} 23 | IMG_WIDTH: '50' 24 | FONT_SIZE: '14' 25 | PATH: '/README.md' 26 | COMMIT_MESSAGE: 'docs(README): update contributors' 27 | AVATAR_SHAPE: 'round' 28 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build & Publish Docker image 2 | on: 3 | release: 4 | types: [ created ] 5 | secrets: 6 | DOCKER_USERNAME: 7 | required: true 8 | DOCKER_TOKEN: 9 | required: true 10 | workflow_dispatch: 11 | inputs: 12 | version: 13 | type: string 14 | description: Version number 15 | required: true 16 | jobs: 17 | publish: 18 | name: Build & Publish to DockerHub and GitHub Packages 19 | runs-on: ubuntu-22.04 20 | if: contains(github.event.head_commit.message, '[no build]') == false 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Establish variables 28 | id: vars 29 | run: | 30 | echo "app_name=tauticord" >> "$GITHUB_OUTPUT" 31 | echo "version=${{ github.event.inputs.version || github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" 32 | echo "major_version=$(echo ${{ github.event.inputs.version || github.event.release.tag_name }} | cut -d '.' -f 1)" >> "$GITHUB_OUTPUT" 33 | echo "today=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" 34 | echo "year=$(date +'%Y')" >> "$GITHUB_OUTPUT" 35 | 36 | - name: Display variables 37 | run: | 38 | echo "Version: ${{ steps.vars.outputs.version }}" 39 | echo "Today: ${{ steps.vars.outputs.today }}" 40 | echo "Year: ${{ steps.vars.outputs.year }}" 41 | 42 | - name: Update version number 43 | uses: jacobtomlinson/gha-find-replace@2.0.0 44 | with: 45 | find: "VERSIONADDEDBYGITHUB" 46 | replace: "${{ steps.vars.outputs.version }}" 47 | regex: false 48 | 49 | - name: Update copyright year 50 | uses: jacobtomlinson/gha-find-replace@2.0.0 51 | with: 52 | find: "YEARADDEDBYGITHUB" 53 | replace: "${{ steps.vars.outputs.year }}" 54 | regex: false 55 | 56 | - name: Update Discord ID 57 | uses: jacobtomlinson/gha-find-replace@2.0.0 58 | with: 59 | find: "DISCORDIDADDEDBYGITHUB" 60 | replace: "${{ secrets.DISCORD_ID }}" 61 | regex: false 62 | 63 | - name: Set up QEMU 64 | uses: docker/setup-qemu-action@v2 65 | 66 | - name: Set up Docker Buildx 67 | uses: docker/setup-buildx-action@v2 68 | id: docker-buildx 69 | 70 | - name: Login to DockerHub 71 | uses: docker/login-action@v3 72 | with: 73 | username: ${{ secrets.DOCKER_USERNAME }} 74 | password: ${{ secrets.DOCKER_TOKEN }} 75 | 76 | - name: Login to GitHub Container Registry 77 | uses: docker/login-action@v3 78 | with: 79 | registry: ghcr.io 80 | username: ${{ github.repository_owner }} 81 | password: ${{ secrets.GITHUB_TOKEN }} 82 | 83 | - name: Login to Gitea Container Registry 84 | uses: docker/login-action@v3 85 | with: 86 | registry: ${{ secrets.GITEA_REGISTRY }} 87 | username: ${{ secrets.GITEA_USERNAME }} 88 | password: ${{ secrets.GITEA_TOKEN }} 89 | 90 | - name: Build and push 91 | uses: docker/build-push-action@v3 92 | with: 93 | builder: ${{ steps.docker-buildx.outputs.name }} 94 | context: . 95 | file: ./Dockerfile 96 | push: true 97 | platforms: linux/amd64,linux/armhf,linux/arm64 98 | tags: | 99 | nwithan8/${{ steps.vars.outputs.app_name }}:latest 100 | nwithan8/${{ steps.vars.outputs.app_name }}:${{ steps.vars.outputs.version }} 101 | nwithan8/${{ steps.vars.outputs.app_name }}:${{ steps.vars.outputs.major_version }} 102 | ghcr.io/nwithan8/${{ steps.vars.outputs.app_name }}:latest 103 | ghcr.io/nwithan8/${{ steps.vars.outputs.app_name }}:${{ steps.vars.outputs.version }} 104 | ghcr.io/nwithan8/${{ steps.vars.outputs.app_name }}:${{ steps.vars.outputs.major_version }} 105 | ${{ secrets.GITEA_REGISTRY }}/nwithan8/${{ steps.vars.outputs.app_name }}:latest 106 | ${{ secrets.GITEA_REGISTRY }}/nwithan8/${{ steps.vars.outputs.app_name }}:${{ steps.vars.outputs.version }} 107 | ${{ secrets.GITEA_REGISTRY }}/nwithan8/${{ steps.vars.outputs.app_name }}:${{ steps.vars.outputs.major_version }} 108 | labels: | 109 | org.opencontainers.image.title=${{ steps.vars.outputs.app_name }} 110 | org.opencontainers.image.version=${{ steps.vars.outputs.version }} 111 | org.opencontainers.image.created=${{ steps.vars.outputs.today }} 112 | -------------------------------------------------------------------------------- /.github/workflows/on_commit.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request checks 2 | on: 3 | pull_request: ~ 4 | workflow_dispatch: ~ 5 | 6 | jobs: 7 | config_validation: 8 | name: Config Validation 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v3 13 | - name: set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: '3.11' 17 | - name: Install dependencies 18 | run: pip install -r ci/requirements.txt 19 | - name: Validate Config 20 | run: python ci/validate_example_config.py .schema/config_v2.schema.json tauticord.yaml.example 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/sponsors.yml: -------------------------------------------------------------------------------- 1 | name: Update sponsors 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: 30 2 * * * # Run every day at 2:30am 6 | 7 | jobs: 8 | update_sponsors: 9 | name: Add all sponsors to README 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v2.4.0 14 | with: 15 | fetch-depth: '0' 16 | 17 | - name: Generate sponsors list 18 | uses: JamesIves/github-sponsors-readme-action@v1 19 | with: 20 | token: ${{ secrets.AC_PAT }} 21 | file: 'README.md' 22 | template: '{{{ login }}}  ' 23 | marker: 'github-sponsors' 24 | active-only: false 25 | 26 | - name: Create pull request 27 | id: cpr 28 | uses: peter-evans/create-pull-request@v6 29 | with: 30 | add-paths: | 31 | README.md 32 | commit-message: 'docs(README): update sponsors' 33 | branch: 'automated/update-sponsors' 34 | delete-branch: true 35 | title: 'docs(README): update sponsors' 36 | body: 'This pull request updates the list of sponsors in the README.' 37 | assignees: 'nwithan8' 38 | labels: | 39 | Automated PR 40 | 41 | - name: Display pull request information 42 | if: ${{ steps.cpr.outputs.pull-request-number }} 43 | run: | 44 | echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}" 45 | echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" 46 | 47 | - name: Enable Pull Request Automerge 48 | if: steps.cpr.outputs.pull-request-operation == 'created' 49 | uses: peter-evans/enable-pull-request-automerge@v3 50 | with: 51 | token: ${{ secrets.AC_PAT }} 52 | pull-request-number: ${{ steps.cpr.outputs.pull-request-number }} 53 | merge-method: squash 54 | -------------------------------------------------------------------------------- /.github/workflows/upload_unraid_template.yml: -------------------------------------------------------------------------------- 1 | name: Copy Unraid Community Applications template(s) to templates repository 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | workflow_dispatch: ~ 7 | 8 | jobs: 9 | copy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Establish variables 18 | id: vars 19 | run: | 20 | echo "version=${{ github.event.inputs.version || github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" 21 | echo "today=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" 22 | echo "year=$(date +'%Y')" >> "$GITHUB_OUTPUT" 23 | 24 | - name: Display variables 25 | run: | 26 | echo "Version: ${{ steps.vars.outputs.version }}" 27 | echo "Today: ${{ steps.vars.outputs.today }}" 28 | echo "Year: ${{ steps.vars.outputs.year }}" 29 | 30 | - name: Open PR with template changes to unraid_templates 31 | uses: nwithan8/action-pull-request-another-repo@v1.1.1 32 | env: 33 | API_TOKEN_GITHUB: ${{ secrets.PR_OPEN_GITHUB_TOKEN }} 34 | with: 35 | # Will mirror folder structure (copying "templates" folder to "templates" folder in destination repo) 36 | source_folder: 'templates' 37 | destination_repo: 'nwithan8/unraid_templates' 38 | destination_base_branch: 'main' 39 | destination_head_branch: tauticord-${{ steps.vars.outputs.version }} 40 | user_email: 'nwithan8@users.noreply.github.com' 41 | user_name: 'nwithan8' 42 | pull_request_assignees: 'nwithan8' 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Custom 141 | config.yaml 142 | tauticord.yaml 143 | /docker-compose_test.yml 144 | /scratch.py 145 | /on_host/ 146 | 00*.yaml 147 | .migration_* 148 | /reference/ 149 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Node.js 18.19 and Python 3.11.x pre-installed on Alpine Linux 3.19 2 | FROM nwithan8/python-3.x-node-18.19.0-alpine3.19:latest 3 | WORKDIR /app 4 | 5 | # Copy requirements.txt from build machine to WORKDIR (/app) folder (important we do this BEFORE copying the rest of the files to avoid re-running pip install on every code change) 6 | COPY requirements.txt requirements.txt 7 | 8 | # Python virtual environment already exists in base image as /app/venv 9 | 10 | # Install Python requirements 11 | # Ref: https://github.com/python-pillow/Pillow/issues/1763 12 | RUN LIBRARY_PATH=/lib:/usr/lib /bin/sh -c "/app/venv/bin/pip install --no-cache-dir setuptools_rust" # https://github.com/docker/compose/issues/8105#issuecomment-775931324 13 | RUN LIBRARY_PATH=/lib:/usr/lib /bin/sh -c "/app/venv/bin/pip install --no-cache-dir -r requirements.txt" 14 | 15 | # Make Docker /config volume for optional config file 16 | VOLUME /config 17 | 18 | # Make Docker /logs volume for log file 19 | VOLUME /logs 20 | 21 | # Copy source code from build machine to WORKDIR (/app) folder 22 | COPY . . 23 | 24 | # Expose port 8283 for API 25 | EXPOSE 8283 26 | 27 | # Delete unnecessary files in WORKDIR (/app) folder (not caught by .dockerignore) 28 | RUN echo "**** removing unneeded files ****" 29 | 30 | # Run entrypoint.sh script 31 | ENTRYPOINT ["sh", "entrypoint.sh"] 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | UNIX_BIN = .venv/bin 2 | WINDOWS_BIN = .venv\Scripts 3 | 4 | install-unix: 5 | ${UNIX_BIN}/pip install -r requirements.txt 6 | 7 | install-win: 8 | ${WINDOWS_BIN}\pip.exe install -r .\requirements.txt 9 | 10 | validate-example-config-unix: 11 | ${UNIX_BIN}/python ci/validate_example_config.py .schema/config_v2.schema.json tauticord.yaml.example 12 | 13 | validate-example-config-win: 14 | ${WINDOWS_BIN}\python.exe ci\validate_example_config.py .schema\config_v2.schema.json tauticord.yaml.example 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Tauticord 4 | 5 | A Discord bot that displays live data from Tautulli 6 | 7 | [![Release](https://img.shields.io/github/v/release/nwithan8/tauticord?color=yellow&include_prereleases&label=version&style=flat-square)](https://github.com/nwithan8/tauticord/releases) 8 | [![Docker](https://img.shields.io/docker/pulls/nwithan8/tauticord?style=flat-square)](https://hub.docker.com/r/nwithan8/tauticord) 9 | [![Licence](https://img.shields.io/github/license/nwithan8/tauticord?style=flat-square)](https://opensource.org/licenses/GPL-3.0) 10 | 11 | Buy Me A Coffee 12 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/nwithan8?style=flat-square)](https://github.com/sponsors/nwithan8) 13 | 14 | logo 15 | 16 |
17 | 18 | # Features ⚙️ 19 | 20 | Tauticord uses the Tautulli API to pull information from Tautulli and display them in a Discord channel, including: 21 | 22 | ### Overview: 23 | 24 | * Number of current streams 25 | * Number of transcoding streams 26 | * Total bandwidth 27 | * Total LAN bandwidth 28 | * Total remote bandwidth 29 | * Library item counts 30 | 31 | ### For each stream: 32 | 33 | * Stream state (playing, paused, stopped, loading) 34 | * Media type (tv show/movie/song/photo) 35 | * User 36 | * Media title 37 | * Product and player 38 | * Quality profile 39 | * Stream bandwidth 40 | * If stream is transcoding 41 | * Progress of stream 42 | * ETA of stream completion 43 | 44 | 45 | 46 | Administrator (the bot owner) can react to Tauticord's messages to terminate a specific stream (if they have Plex Pass). 47 | 48 | Users can also indicate what libraries they would like monitored. Tauticord will create/update a voice channel for each 49 | library name with item counts every hour. 50 | 51 | 52 | 53 | # Announcements 📢 54 | 55 | See [ANNOUNCEMENTS](documentation/ANNOUNCEMENTS.md). 56 | 57 | # Documentation 📄 58 | 59 | See [DOCUMENTATION](documentation/DOCUMENTATION.md). 60 | 61 | # Development 🛠️ 62 | 63 | See [DEVELOPMENT](documentation/DEVELOPMENT.md). 64 | 65 | # Contact 📧 66 | 67 | Please leave a pull request if you would like to contribute. 68 | 69 | Also feel free to check out my other projects here on [GitHub](https://github.com/nwithan8) or join the `#developer` 70 | channel in my Discord server below. 71 | 72 |
73 |

74 | 75 |

76 |
77 | 78 | ## Shout-outs 🎉 79 | 80 | ### Sponsors 81 | 82 | [![JetBrains](https://avatars.githubusercontent.com/u/60931315?s=120&v=4)](https://github.com/JetBrainsOfficial) 83 | 84 | upamanyudas  l33xu   85 | 86 | 87 | 88 | 89 | 90 | ### Contributors 91 | 92 | 93 | 94 | 101 | 108 | 115 | 122 | 123 | 124 | 131 | 138 | 139 |
95 | 96 | Nate 97 |
98 | Nate Harris 99 |
100 |
102 | 103 | Thomas 104 |
105 | Thomas White 106 |
107 |
109 | 110 | Tim 111 |
112 | Tim Wilson 113 |
114 |
116 | 117 | Ben 118 |
119 | Ben Waco 120 |
121 |
125 | 126 | Thomas 127 |
128 | Thomas Durieux 129 |
130 |
132 | 133 | Roy 134 |
135 | Roy Du 136 |
137 |
140 | 141 | 142 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from flask import ( 4 | Flask, 5 | ) 6 | 7 | import modules.logs as logging 8 | from modules.errors import determine_exit_code 9 | from consts import ( 10 | DEFAULT_LOG_DIR, 11 | DEFAULT_DATABASE_PATH, 12 | CONSOLE_LOG_LEVEL, 13 | FILE_LOG_LEVEL, 14 | FLASK_ADDRESS, 15 | FLASK_PORT, 16 | FLASK_DATABASE_PATH, 17 | ) 18 | from api.routes.index import index 19 | from api.routes.webhooks.tautulli.index import webhooks_tautulli 20 | 21 | APP_NAME = "API" 22 | 23 | # Parse CLI arguments 24 | parser = argparse.ArgumentParser(description="Tauticord API - API for Tauticord") 25 | """ 26 | Bot will use config, in order: 27 | 1. Explicit config file path provided as CLI argument, if included, or 28 | 2. Default config file path, if exists, or 29 | 3. Environmental variables 30 | """ 31 | parser.add_argument("-l", "--log", help="Log file directory", default=DEFAULT_LOG_DIR) 32 | parser.add_argument("-d", "--database", help="Path to database file", default=DEFAULT_DATABASE_PATH) 33 | args = parser.parse_args() 34 | 35 | 36 | def run_with_potential_exit_on_error(func): 37 | def wrapper(*args, **kwargs): 38 | try: 39 | return func(*args, **kwargs) 40 | except Exception as e: 41 | logging.fatal(f"Fatal error occurred. Shutting down: {e}") 42 | exit_code = determine_exit_code(exception=e) 43 | logging.fatal(f"Exiting with code {exit_code}") 44 | exit(exit_code) 45 | 46 | return wrapper 47 | 48 | 49 | @run_with_potential_exit_on_error 50 | def set_up_logging(): 51 | logging.init(app_name=APP_NAME, 52 | console_log_level=CONSOLE_LOG_LEVEL, 53 | log_to_file=True, 54 | log_file_dir=args.log, 55 | file_log_level=FILE_LOG_LEVEL) 56 | 57 | 58 | # Register Flask blueprints 59 | application = Flask(APP_NAME) 60 | application.config[FLASK_DATABASE_PATH] = args.database 61 | 62 | application.register_blueprint(index) 63 | application.register_blueprint(webhooks_tautulli) 64 | 65 | if __name__ == "__main__": 66 | set_up_logging() 67 | 68 | application.run(host=FLASK_ADDRESS, port=FLASK_PORT, debug=False, use_reloader=False) 69 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/api/__init__.py -------------------------------------------------------------------------------- /api/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/api/controllers/__init__.py -------------------------------------------------------------------------------- /api/controllers/webhook_processor.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from flask import ( 4 | jsonify, 5 | request as flask_request, 6 | ) 7 | import modules.logs as logging 8 | 9 | import modules.database.repository as db 10 | from modules.webhooks import RecentlyAddedWebhook 11 | 12 | 13 | class WebhookProcessor: 14 | def __init__(self): 15 | pass 16 | 17 | @staticmethod 18 | def process_tautulli_recently_added_webhook(request: flask_request, database_path: str) -> [Union[str, None], int]: 19 | """ 20 | Process a configured recently-added webhook from Tautulli. 21 | Return an empty response and a 200 status code back to Tautulli as confirmation. 22 | """ 23 | webhook: RecentlyAddedWebhook = RecentlyAddedWebhook.from_flask_request(request=request) 24 | 25 | if webhook: 26 | database = db.DatabaseRepository(database_path=database_path) 27 | _ = database.add_received_recently_added_webhook_to_database(webhook=webhook) 28 | else: 29 | logging.debug("Received invalid recently-added webhook from Tautulli") 30 | 31 | return jsonify({}), 200 32 | -------------------------------------------------------------------------------- /api/routes/index.py: -------------------------------------------------------------------------------- 1 | from flask import ( 2 | Blueprint, 3 | request, 4 | Response as FlaskResponse, 5 | ) 6 | 7 | from consts import ( 8 | FLASK_POST, 9 | FLASK_GET, 10 | ) 11 | 12 | index = Blueprint("index", __name__, url_prefix="") 13 | 14 | 15 | @index.route("/ping", methods=[FLASK_GET]) 16 | def ping(): 17 | return 'Pong!', 200 18 | 19 | 20 | @index.route("/hello", methods=[FLASK_GET]) 21 | def hello_world(): 22 | return 'Hello, World!', 200 23 | 24 | 25 | @index.route("/health", methods=[FLASK_GET]) 26 | def health_check(): 27 | return 'OK', 200 28 | -------------------------------------------------------------------------------- /api/routes/webhooks/tautulli/index.py: -------------------------------------------------------------------------------- 1 | from flask import ( 2 | Blueprint, 3 | request as flask_request, 4 | Response as FlaskResponse, 5 | current_app, 6 | ) 7 | 8 | from api.controllers.webhook_processor import WebhookProcessor 9 | from consts import ( 10 | FLASK_POST, 11 | FLASK_GET, 12 | FLASK_DATABASE_PATH, 13 | ) 14 | 15 | webhooks_tautulli = Blueprint("tautulli", __name__, url_prefix="/webhooks/tautulli") 16 | 17 | 18 | @webhooks_tautulli.route("/recently_added", methods=[FLASK_POST]) 19 | def tautulli_webhook(): 20 | database_path = current_app.config[FLASK_DATABASE_PATH] 21 | return WebhookProcessor.process_tautulli_recently_added_webhook(request=flask_request, 22 | database_path=database_path) 23 | -------------------------------------------------------------------------------- /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | jsonschema 2 | pyyaml 3 | -------------------------------------------------------------------------------- /ci/validate_example_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import json 5 | from collections import deque 6 | 7 | import jsonschema 8 | import yaml 9 | 10 | parser = argparse.ArgumentParser() 11 | parser.add_argument('schema', help='Path to the YAML schema file') 12 | parser.add_argument('file', help='Path to the YAML file to validate') 13 | 14 | args = parser.parse_args() 15 | schema_file = args.schema 16 | yaml_file = args.file 17 | 18 | # Load the YAML file to validate 19 | with open(yaml_file, 'r') as file: 20 | data = yaml.safe_load(file) 21 | 22 | # Load the schema 23 | with open(schema_file, 'r') as file: 24 | schema = json.load(file) 25 | 26 | 27 | def make_properties_required(partial_schema: dict): 28 | if 'properties' not in partial_schema.keys(): 29 | return partial_schema 30 | 31 | properties = {} 32 | required = [] 33 | 34 | for prop, details in partial_schema['properties'].items(): 35 | details = make_properties_required(partial_schema=details) 36 | properties[prop] = details 37 | 38 | required.append(prop) 39 | 40 | partial_schema['properties'] = properties 41 | partial_schema['required'] = required 42 | 43 | return partial_schema 44 | 45 | 46 | # Make every property in the schema required 47 | schema = make_properties_required(partial_schema=schema) 48 | 49 | known_paths_with_validation_issues_to_ignore = [ 50 | ["Discord", "BotToken"], 51 | ["Tautulli", "APIKey"], 52 | ] 53 | 54 | validator = jsonschema.Draft7Validator(schema) 55 | errors_occurred = False 56 | 57 | for error in validator.iter_errors(data): 58 | path = deque(error.absolute_path) 59 | 60 | if list(path) in known_paths_with_validation_issues_to_ignore: 61 | print(f"Ignoring error in {path}: {error.message}") 62 | continue 63 | 64 | errors_occurred = True 65 | print(f"Error in {path}: {error.message}") 66 | 67 | if errors_occurred: 68 | exit(1) 69 | else: 70 | print("No errors found") 71 | exit(0) 72 | -------------------------------------------------------------------------------- /consts.py: -------------------------------------------------------------------------------- 1 | APP_NAME = "Tauticord" 2 | GOOGLE_ANALYTICS_ID = 'UA-174268200-2' 3 | DEFAULT_CONFIG_PATH = "/config/tauticord.yaml" 4 | DEFAULT_LOG_DIR = "/logs/" 5 | DEFAULT_DATABASE_PATH = "/config/tauticord.db" 6 | CONSOLE_LOG_LEVEL = "INFO" 7 | FILE_LOG_LEVEL = "DEBUG" 8 | GITHUB_REPO = "nwithan8/tauticord" 9 | GITHUB_REPO_FULL_LINK = f"https://github.com/{GITHUB_REPO}" 10 | GITHUB_REPO_MASTER_BRANCH = "master" 11 | FLASK_ADDRESS = "0.0.0.0" 12 | FLASK_PORT = 8283 13 | FLASK_POST = "POST" 14 | FLASK_GET = "GET" 15 | FLASK_DATABASE_PATH = "FLASK_DATABASE_PATH" 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | tauticord: 4 | image: nwithan8/tauticord:latest 5 | ports: 6 | - "8283:8283" 7 | volumes: 8 | - /path/to/config:/config 9 | - /path/to/logs:/logs 10 | - /path/to/monitored/disk:/monitor 11 | -------------------------------------------------------------------------------- /documentation/ANNOUNCEMENTS.md: -------------------------------------------------------------------------------- 1 | ## Dropping Support for v2.14.0 and v2.14.1 of Tautulli 2 | 3 | **Date Posted**: 2024-11-24 4 | 5 | **Latest Release**: *v5.7.3* 6 | 7 | **Affected Release**: *v5.8.0+* 8 | 9 | **Affected Users**: Those using Tauticord with Tautulli versions v2.14.0 and v2.14.1 10 | 11 | An upcoming minor release of Tauticord will drop support for Tautulli versions v2.14.0 and v2.14.1. This is due to the 12 | minimum required API version of the `tautulli` API library, which has been updated. 13 | 14 | Tautulli v2.14.0 and v2.14.1 were technically beta releases of Tautulli v2.14.X, and did not play nicely with the 15 | minimum API version enforcement capabilities of the `tautulli` API library (which are enforced by Tauticord). 16 | 17 | Any users still running Tautulli v2.14.0 or v2.14.1 should upgrade to a newer version of Tautulli to continue using 18 | Tauticord. 19 | 20 | If you need to continue using Tauticord with Tautulli versions v2.14.0 or v2.14.1, you will need to pin your Tauticord 21 | version to an earlier version using Docker tags: https://hub.docker.com/r/nwithan8/tauticord/tags 22 | 23 | --- 24 | 25 | ## Dropping Support for v2.12.x and v2.13.x of Tautulli 26 | 27 | **Date Posted**: 2024-04-19 28 | 29 | **Latest Release**: *v5.3.4* 30 | 31 | **Affected Release**: *v5.5.0+* 32 | 33 | **Affected Users**: Those using Tauticord with Tautulli versions v2.12.x and v2.13.x 34 | 35 | An upcoming minor release of Tauticord will drop support for Tautulli versions v2.12.x and v2.13.x. This is due to the 36 | release of Tautulli v2.14.0, which introduced breaking changes to the API. 37 | 38 | Because Tauticord enforces strict compatibility checking with Tautulli, the underlying `tautulli` API library will need 39 | to be updated to accommodate v2.14.0. Doing so will drop v2.12.x and v2.13.x support at the same time. 40 | 41 | If you need to continue using Tauticord with Tautulli versions v2.12.x or v2.13.x, you will need to pin your Tauticord 42 | version to an earlier version using Docker tags: https://hub.docker.com/r/nwithan8/tauticord/tags 43 | 44 | --- 45 | 46 | ## Removing Environmental Variables in Unraid Community Applications Template 47 | 48 | **Date Posted**: 2024-03-30 49 | 50 | **Latest Release:** *v4.2.1* 51 | 52 | **Affected Release:** *v5.0.0+* 53 | 54 | **Affected Users:** Those using the **Unraid Community Applications** template to deploy Tauticord 55 | 56 | In the next major release of Tauticord, we will be removing support for configuring Tauticord via environmental 57 | variables. 58 | 59 | Instead, Tauticord will be configured using a `tauticord.yaml` file located in the `/config` directory. 60 | 61 | `v4.2.0` and `v4.2.1` of Tauticord include a built-in migration system that automatically converts existing 62 | environmental variable configurations to a YAML configuration file. The bot still operates using environmental 63 | variables; the migration output will be used by additional migrations in the upcoming `v5.0.0` release. 64 | 65 | As a result, the **Unraid Community Applications** template will no longer include the option to configure Tauticord 66 | using environmental variables. The template already requires users to provide a `/config` directory path mapping, so 67 | this change is purely subtractive. 68 | 69 | It is unclear if users downloading the new template will have their existing configurations erased and/or reset. **We 70 | are under the assumption that this will effectively reset the existing configuration for Tauticord.** We recommend 71 | taking a screenshot of your current configuration before updating, so you can easily reconfigure Tauticord after the 72 | update. 73 | 74 | --- 75 | 76 | ## Adding Dropdown Options in Unraid Community Applications Template 77 | 78 | **Date Posted**: 2024-03-24 79 | 80 | **Latest Release:** *v4.1.4* 81 | 82 | **Affected Release:** *v4.2.0+* 83 | 84 | **Affected Users:** Those using the **Unraid Community Applications** template to deploy Tauticord 85 | 86 | In the next minor release of Tauticord, we will be adding dropdown options to the **Unraid Community Applications**. 87 | 88 | The majority of the configuration options for Tauticord are currently boolean in nature, and require either "True" or " 89 | False" as values. The fact that users have to provide the words "True" or "False" can be confusing, and have seen users 90 | provide incorrect values accidentally. 91 | 92 | Unraid Community Application templates offer a way to instead provide a set of predefined values, which will appear as a 93 | dropdown menu in the UI when configuring a container. This will make it easier for users to select the correct values 94 | for the configuration options. 95 | 96 | It is unclear if implementing this feature will erase and/or reset existing configurations for those fields. **We are 97 | under the assumption that this will effectively reset the existing configuration for Tauticord.** 98 | 99 | We have made every attempt to set each field to the best default value, but we recommend double-checking your 100 | configuration after updating to the new version. We recommend taking a screenshot of your current configuration before 101 | updating, so you can easily reconfigure Tauticord after the update. 102 | 103 | --- 104 | 105 | ## Dropping Python Script Support 106 | 107 | **Date Posted**: 2024-03-24 108 | 109 | **Latest Release:** *v4.1.4* 110 | 111 | **Affected Release:** *v5.0.0+* 112 | 113 | **Affected Users:** Those running Tauticord as a standalone Python script 114 | 115 | In an upcoming major release of Tauticord, we will be officially dropping support for running Tauticord as a standalone 116 | Python script. Running Tauticord as a Docker container will be the only officially-supported method of deployment. 117 | 118 | Running Tauticord as a standalone Python script has been considered deprecated and flagged as "not recommended" for a 119 | while; as of the new release, we will NO LONGER offer support/assistance for running Tauticord as a standalone Python 120 | script. 121 | 122 | --- 123 | 124 | ## Dropping Environmental Variable Support 125 | 126 | **Date Posted**: 2024-03-24 127 | 128 | **Latest Release:** *v4.1.4* 129 | 130 | **Affected Release:** *v5.0.0+* 131 | 132 | **Affected Users:** Those using environmental variables to configure Tauticord, especially those using the **Unraid 133 | Community Applications** template 134 | 135 | In an upcoming major release of Tauticord, we will be dropping support for configuring Tauticord via environmental 136 | variables. 137 | 138 | As more features get added, handling two different configuration methods has become increasingly difficult, and 139 | environmental variables in particular have proven to be limiting in their capabilities. With an upcoming change to the 140 | configuration process, we will be dropping support for environmental variables entirely. 141 | 142 | In minor versions prior to the major release, we will be adding a warning message to the logs if Tauticord detects that 143 | environmental variables are being used to configure the bot. We will also be incorporating a migration system to help 144 | convert existing environmental variable configurations to a YAML configuration file. 145 | -------------------------------------------------------------------------------- /documentation/DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | This bot is still a work in progress. If you have any ideas for improving or adding to Tauticord, please open an issue 2 | or a pull request. 3 | -------------------------------------------------------------------------------- /documentation/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/banner.png -------------------------------------------------------------------------------- /documentation/images/embed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/embed.png -------------------------------------------------------------------------------- /documentation/images/graphs_play_duration_day_of_week.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/graphs_play_duration_day_of_week.png -------------------------------------------------------------------------------- /documentation/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/icon.png -------------------------------------------------------------------------------- /documentation/images/libraries.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/libraries.png -------------------------------------------------------------------------------- /documentation/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/logo.png -------------------------------------------------------------------------------- /documentation/images/message_content_intent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/message_content_intent.png -------------------------------------------------------------------------------- /documentation/images/most_active_libraries.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/most_active_libraries.png -------------------------------------------------------------------------------- /documentation/images/permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/permissions.png -------------------------------------------------------------------------------- /documentation/images/recently_added.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/recently_added.png -------------------------------------------------------------------------------- /documentation/images/tauticord_webhook_config_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/tauticord_webhook_config_1.png -------------------------------------------------------------------------------- /documentation/images/tauticord_webhook_config_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/tauticord_webhook_config_2.png -------------------------------------------------------------------------------- /documentation/images/tauticord_webhook_config_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/documentation/images/tauticord_webhook_config_3.png -------------------------------------------------------------------------------- /ecosystem.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "keepalive", 5 | "interpreter": "/app/venv/bin/python", 6 | "script": "pm2_keepalive.py", 7 | "autorestart": true, 8 | "exec_mode": "fork", 9 | "instances": 1 10 | }, 11 | { 12 | "name": "api", 13 | "interpreter": "/app/venv/bin/python", 14 | "script": "api.py", 15 | "autorestart": true, 16 | "exec_mode": "fork", 17 | "instances": 1, 18 | "stop_exit_codes": [302] 19 | }, 20 | { 21 | "name": "tauticord", 22 | "interpreter": "/app/venv/bin/python", 23 | "script": "run.py", 24 | "autorestart": true, 25 | "combine_logs": true, 26 | // Auto restart on config change 27 | "watch": ["/config/tauticord.yaml"], 28 | "watch_delay": 1000, 29 | "exec_mode": "fork", 30 | "instances": 1, 31 | // 101 - Discord login failed 32 | // 102 - Missing privileged intents 33 | // 301 - Migrations failed 34 | "stop_exit_codes": [101, 102, 301, 302] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Delete all /tmp files on container start 4 | echo "Deleting all /tmp files on container start" 5 | rm -rf /tmp/charts/* || true 6 | 7 | # Create cron directory 8 | mkdir -p /etc/cron.d 9 | 10 | # Cron schedule for cleaning up old files (every hour) 11 | CLEANUP_CRON_SCHEDULE="0 * * * *" # Every hour 12 | CLEANUP_CRON_FILE="/etc/cron.d/cleanup_temp_files" 13 | 14 | # Schedule cron job with supplied cron schedule 15 | echo "Scheduling cleanup cron job" 16 | echo "$CLEANUP_CRON_SCHEDULE find /tmp -type f -mmin +60 -delete > /proc/1/fd/1 2>/proc/1/fd/2" > $CLEANUP_CRON_FILE 17 | 18 | # Give execution rights on the cron job 19 | chmod 0644 $CLEANUP_CRON_FILE 20 | 21 | # Apply cleanup cron job 22 | echo "Applying cleanup cron job" 23 | crontab $CLEANUP_CRON_FILE 24 | 25 | # Start cron and start the application 26 | crond && pm2-runtime start ecosystem.config.json 27 | -------------------------------------------------------------------------------- /legacy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/legacy/__init__.py -------------------------------------------------------------------------------- /legacy/statics.py: -------------------------------------------------------------------------------- 1 | # Number 1-9, and A-Z 2 | import subprocess 3 | import sys 4 | 5 | VERSION = "VERSIONADDEDBYGITHUB" 6 | COPYRIGHT = "Copyright © YEARADDEDBYGITHUB Nate Harris. All rights reserved." 7 | UNKNOWN_COMMIT_HASH = "unknown-commit" 8 | 9 | STANDARD_EMOJIS_FOLDER = "resources/emojis/standard" 10 | NITRO_EMOJIS_FOLDER = "resources/emojis/nitro" 11 | 12 | MONITORED_DISK_SPACE_FOLDER = "/monitor" 13 | 14 | BOT_PREFIX = "tc-" 15 | 16 | VOICE_CHANNEL_ORDER = { 17 | 'count': 1, 18 | 'transcodes': 2, 19 | 'bandwidth': 3, 20 | 'localBandwidth': 4, 21 | 'remoteBandwidth': 5 22 | } 23 | 24 | MAX_EMBED_FIELD_NAME_LENGTH = 200 # 256 - estimated emojis + flairs + safety margin 25 | 26 | KEY_STATS_CATEGORY_NAME = "stats_category_name" 27 | KEY_COUNT = "count" 28 | KEY_TRANSCODE_COUNT = "transcode_count" 29 | KEY_BANDWIDTH = "bandwidth" 30 | KEY_LAN_BANDWIDTH = "lan_bandwidth" 31 | KEY_REMOTE_BANDWIDTH = "remote_bandwidth" 32 | KEY_STATS = "stats" 33 | KEY_PLEX_STATUS = "plex_status" 34 | KEY_PLEX_STATUS_USE_EMOJI = "plex_status_emoji" 35 | KEY_REFRESH_TIME = "refresh_time" 36 | KEY_LIBRARIES_CATEGORY_NAME = "libraries_category_name" 37 | KEY_LIBRARIES = "libraries" 38 | KEY_COMBINED_LIBRARIES = "combined_libraries" 39 | KEY_USE_EMOJIS = "use_emojis" 40 | KEY_SHOW_TV_EPISODES = "show_tv_episodes" 41 | KEY_SHOW_TV_SERIES = "show_tv_series" 42 | KEY_SHOW_MUSIC_ARTISTS = "show_music_artists" 43 | KEY_SHOW_MUSIC_ALBUMS = "show_music_albums" 44 | KEY_SHOW_MUSIC_TRACKS = "show_music_tracks" 45 | KEY_STATS_CHANNEL_IDS = "stat_channel_ids" 46 | KEY_USE_STATS_CHANNEL_IDS = "use_stat_channel_ids" 47 | KEY_STREAM_COUNT_CHANNEL_ID = "stream_count_channel_id" 48 | KEY_TRANSCODE_COUNT_CHANNEL_ID = "transcode_count_channel_id" 49 | KEY_BANDWIDTH_CHANNEL_ID = "bandwidth_channel_id" 50 | KEY_LAN_BANDWIDTH_CHANNEL_ID = "lan_bandwidth_channel_id" 51 | KEY_REMOTE_BANDWIDTH_CHANNEL_ID = "remote_bandwidth_channel_id" 52 | KEY_PLEX_STATUS_CHANNEL_ID = "plex_status_channel_id" 53 | KEY_STATUS = "status" 54 | 55 | KEY_TIME_MANAGER = "time_settings" 56 | 57 | KEY_HIDE_USERNAMES = "hide_usernames" 58 | KEY_HIDE_PLATFORMS = "hide_platforms" 59 | KEY_HIDE_PLAYER_NAMES = "anonymize_players" 60 | KEY_HIDE_QUALITY = "hide_quality" 61 | KEY_HIDE_BANDWIDTH = "hide_bandwidth" 62 | KEY_HIDE_TRANSCODING = "hide_transcoding" 63 | KEY_HIDE_PROGRESS = "hide_progress" 64 | KEY_HIDE_ETA = "hide_eta" 65 | KEY_USE_FRIENDLY_NAMES = "use_friendly_names" 66 | 67 | KEY_PERFORMANCE_CATEGORY_NAME = "performance_category_name" 68 | KEY_PERFORMANCE_MONITOR_TAUTULLI_USER_COUNT = "performance_monitor_tautulli_user_count" 69 | KEY_PERFORMANCE_MONITOR_DISK_SPACE = "performance_monitor_disk_space" 70 | KEY_PERFORMANCE_MONITOR_DISK_SPACE_PATH = "performance_monitor_disk_space_path" 71 | KEY_PERFORMANCE_MONITOR_CPU = "performance_monitor_cpu" 72 | KEY_PERFORMANCE_MONITOR_MEMORY = "performance_monitor_memory" 73 | KEY_RUN_ARGS_MONITOR_PATH = "run_args_monitor_path" 74 | KEY_RUN_ARGS_CONFIG_PATH = "run_args_config_path" 75 | KEY_RUN_ARGS_LOG_PATH = "run_args_log_path" 76 | 77 | MAX_STREAM_COUNT = 36 78 | 79 | ENCODING_SEPARATOR_1 = "%" 80 | ENCODING_SEPARATOR_2 = "#" 81 | ENCODING_SEPARATOR_3 = "@" 82 | 83 | ASCII_ART = """___________________ ______________________________________________ 84 | ___ __/__ |_ / / /__ __/___ _/_ ____/_ __ \__ __ \__ __ \\ 85 | __ / __ /| | / / /__ / __ / _ / _ / / /_ /_/ /_ / / / 86 | _ / _ ___ / /_/ / _ / __/ / / /___ / /_/ /_ _, _/_ /_/ / 87 | /_/ /_/ |_\____/ /_/ /___/ \____/ \____/ /_/ |_| /_____/ 88 | """ 89 | 90 | INFO_SUMMARY = f"""Version: {VERSION} 91 | """ 92 | 93 | 94 | def get_sha_hash(sha: str) -> str: 95 | return f"git-{sha[0:7]}" 96 | 97 | 98 | def get_last_commit_hash() -> str: 99 | """ 100 | Get the seven character commit hash of the last commit. 101 | """ 102 | try: 103 | sha = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8").strip() 104 | except subprocess.SubprocessError: 105 | sha = UNKNOWN_COMMIT_HASH 106 | 107 | return get_sha_hash(sha) 108 | 109 | 110 | def is_git() -> bool: 111 | return "GITHUB" in VERSION 112 | 113 | 114 | def get_version() -> str: 115 | if not is_git(): 116 | return VERSION 117 | 118 | return get_last_commit_hash() 119 | 120 | 121 | def splash_logo() -> str: 122 | version = get_version() 123 | return f""" 124 | {ASCII_ART} 125 | Version {version}, Python {sys.version} 126 | 127 | {COPYRIGHT} 128 | """ 129 | -------------------------------------------------------------------------------- /legacy/text_manager.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, Union 2 | 3 | from modules import statics, utils 4 | from modules.emojis import EmojiManager 5 | from modules.time_manager import TimeManager 6 | from legacy.utils import limit_text_length 7 | 8 | 9 | class TextManager: 10 | """ 11 | Manages text formatting and anonymization. 12 | """ 13 | def __init__(self, rules: Dict[str, Any]) -> None: 14 | self._rules: dict = rules 15 | self._anon_hide_usernames: bool = rules.get(statics.KEY_HIDE_USERNAMES, False) 16 | self._anon_hide_player_names: bool = rules.get(statics.KEY_HIDE_PLAYER_NAMES, False) 17 | self._anon_hide_platforms: bool = rules.get(statics.KEY_HIDE_PLATFORMS, False) 18 | self._anon_hide_quality: bool = rules.get(statics.KEY_HIDE_QUALITY, False) 19 | self._anon_hide_bandwidth: bool = rules.get(statics.KEY_HIDE_BANDWIDTH, False) 20 | self._anon_hide_transcoding: bool = rules.get(statics.KEY_HIDE_TRANSCODING, False) 21 | self._anon_hide_progress: bool = rules.get(statics.KEY_HIDE_PROGRESS, False) 22 | self._anon_hide_eta: bool = rules.get(statics.KEY_HIDE_ETA, False) 23 | self._use_friendly_names: bool = rules.get(statics.KEY_USE_FRIENDLY_NAMES, False) 24 | self._time_manager: TimeManager = rules.get(statics.KEY_TIME_MANAGER, TimeManager()) # fallback should not be needed 25 | 26 | def _session_user_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]: 27 | if self._anon_hide_usernames: 28 | return None 29 | 30 | emoji = emoji_manager.get_emoji(key="person") 31 | username = session.friendly_name if self._use_friendly_names else session.username 32 | stub = f"""{emoji} {utils.bold(username)}""" 33 | 34 | return stub 35 | 36 | def _session_player_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]: 37 | if self._anon_hide_platforms and self._anon_hide_player_names: 38 | return None 39 | 40 | emoji = emoji_manager.get_emoji(key="device") 41 | player = None if self._anon_hide_player_names else session.player 42 | product = None if self._anon_hide_platforms else session.product 43 | 44 | stub = f"""{emoji}""" 45 | if player is not None: 46 | stub += f""" {utils.bold(player)}""" 47 | # Only optionally show product if player is shown. 48 | if product is not None: 49 | stub += f""" ({product})""" 50 | 51 | return stub 52 | 53 | def _session_details_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]: 54 | if self._anon_hide_quality and self._anon_hide_bandwidth and self._anon_hide_transcoding: 55 | return None 56 | 57 | quality_profile = None if self._anon_hide_quality else session.quality_profile 58 | bandwidth = None if self._anon_hide_bandwidth else session.bandwidth 59 | transcoding = None if self._anon_hide_transcoding else session.transcoding_stub 60 | 61 | emoji = emoji_manager.get_emoji(key="resolution") 62 | stub = f"""{emoji}""" 63 | if quality_profile is not None: 64 | stub += f""" {utils.bold(quality_profile)}""" 65 | # Only optionally show bandwidth if quality profile is shown. 66 | if bandwidth is not None: 67 | stub += f""" ({bandwidth})""" 68 | if transcoding is not None: 69 | stub += f"""{transcoding}""" 70 | 71 | return stub 72 | 73 | def _session_progress_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]: 74 | if self._anon_hide_progress: 75 | return None 76 | 77 | emoji = emoji_manager.get_emoji(key="progress") 78 | progress = session.progress_marker 79 | stub = f"""{emoji} {utils.bold(progress)}""" 80 | if not self._anon_hide_eta: 81 | eta = session.eta 82 | stub += f""" (ETA: {eta})""" 83 | 84 | return stub 85 | 86 | def session_title(self, session, session_number: int, emoji_manager: EmojiManager) -> str: 87 | emoji = emoji_manager.emoji_from_stream_number(number=session_number) 88 | icon = session.get_status_icon(emoji_manager=emoji_manager) 89 | media_type_icon = session.get_type_icon(emoji_manager=emoji_manager) 90 | title = session.title 91 | title = limit_text_length(text=title, limit=statics.MAX_EMBED_FIELD_NAME_LENGTH) 92 | return f"""{emoji} | {icon} {media_type_icon} *{title}*""" 93 | 94 | def session_body(self, session, emoji_manager: EmojiManager) -> str: 95 | user_message = self._session_user_message(session=session, emoji_manager=emoji_manager) 96 | player_message = self._session_player_message(session=session, emoji_manager=emoji_manager) 97 | details_message = self._session_details_message(session=session, emoji_manager=emoji_manager) 98 | progress_message = self._session_progress_message(session=session, emoji_manager=emoji_manager) 99 | 100 | stubs = [user_message, player_message, details_message, progress_message] 101 | stubs = [stub for stub in stubs if stub is not None] 102 | return "\n".join(stubs) 103 | 104 | def overview_footer(self, no_connection: bool, activity, emoji_manager: EmojiManager, add_termination_tip: bool) -> str: 105 | if no_connection or activity is None: 106 | return f"{utils.bold('Connection lost.')}" 107 | 108 | if activity.stream_count == 0: 109 | return "" 110 | 111 | stream_count = activity.stream_count 112 | stream_count_word = utils.make_plural(word='stream', count=stream_count) 113 | overview_message = f"""{stream_count} {stream_count_word}""" 114 | 115 | if activity.transcode_count > 0 and not self._anon_hide_transcoding: 116 | transcode_count = activity.transcode_count 117 | transcode_count_word = utils.make_plural(word='transcode', count=transcode_count) 118 | overview_message += f""" ({transcode_count} {transcode_count_word})""" 119 | 120 | if activity.total_bandwidth and not self._anon_hide_bandwidth: 121 | bandwidth_emoji = emoji_manager.get_emoji(key='bandwidth') 122 | bandwidth = activity.total_bandwidth 123 | overview_message += f""" @ {bandwidth_emoji} {bandwidth}""" 124 | if activity.lan_bandwidth: 125 | lan_bandwidth_emoji = emoji_manager.get_emoji(key='home') 126 | lan_bandwidth = activity.lan_bandwidth 127 | overview_message += f""" {lan_bandwidth_emoji} {lan_bandwidth}""" 128 | 129 | if add_termination_tip: 130 | overview_message += f"\n\nTo terminate a stream, react with the stream number." 131 | 132 | return overview_message 133 | -------------------------------------------------------------------------------- /migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/migrations/__init__.py -------------------------------------------------------------------------------- /migrations/base.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from datetime import datetime 3 | 4 | import modules.logs as logging 5 | 6 | 7 | def _marker(undone: bool = False) -> str: 8 | marker = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 9 | 10 | if undone: 11 | marker += " - UNDONE" 12 | 13 | return marker 14 | 15 | 16 | class BaseMigration: 17 | def __init__(self, number: str, migration_data_directory: str): 18 | self.number = number 19 | self.migration_data_directory = migration_data_directory 20 | self._migration_file = f"{migration_data_directory}/.migration_{self.number}" 21 | 22 | def log(self, message: str): 23 | logging.info(f"Migration {self.number}: {message}") 24 | 25 | def error(self, message: str): 26 | logging.error(f"Migration {self.number}: {message}") 27 | 28 | @abstractmethod 29 | def pre_forward_check(self) -> bool: 30 | """ 31 | Check if the forward migration needs to run 32 | """ 33 | return True 34 | 35 | @abstractmethod 36 | def forward(self): 37 | """ 38 | Run the forward migration 39 | """ 40 | pass 41 | 42 | @abstractmethod 43 | def post_forward_check(self) -> bool: 44 | """ 45 | Check if the forward migration was successful 46 | """ 47 | return True 48 | 49 | @abstractmethod 50 | def pre_backwards_check(self) -> bool: 51 | """ 52 | Check if the backwards migration needs to run 53 | """ 54 | return True 55 | 56 | @abstractmethod 57 | def backwards(self): 58 | """ 59 | Run the backwards migration 60 | """ 61 | pass 62 | 63 | @abstractmethod 64 | def post_backwards_check(self) -> bool: 65 | """ 66 | Check if the backwards migration was successful 67 | """ 68 | return True 69 | 70 | def mark_done(self): 71 | """ 72 | Mark the migration as done 73 | """ 74 | with open(self._migration_file, 'a') as f: 75 | f.write(f"{_marker()}\n") 76 | 77 | def mark_undone(self): 78 | """ 79 | Mark the migration as undone 80 | """ 81 | with open(self._migration_file, 'a') as f: 82 | f.write(f"{_marker(undone=True)}\n") 83 | -------------------------------------------------------------------------------- /migrations/migration_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import modules.logs as logging 4 | 5 | from migrations.m001_env_var_to_config_yaml import Migration as Migration001 6 | from migrations.m002_old_config_to_new_config import Migration as Migration002 7 | from migrations.m003_add_recently_added_webhook import Migration as Migration003 8 | 9 | # NOTE: 10 | # MigrationManager assumes you are using the default tauticord.yaml config file name. 11 | # Does not support custom config file names. 12 | 13 | 14 | class MigrationManager: 15 | def __init__(self, migration_data_directory: str, config_directory: str, logs_directory: str): 16 | self.migration_data_directory = migration_data_directory 17 | self.config_directory = config_directory 18 | self.logs_directory = logs_directory 19 | 20 | # Verify directories exist 21 | os.makedirs(self.migration_data_directory, exist_ok=True) 22 | os.makedirs(self.config_directory, exist_ok=True) 23 | os.makedirs(self.logs_directory, exist_ok=True) 24 | 25 | self.migrations = [ 26 | # Copy environment variables to a YAML file (not config.yaml to avoid schema issues) 27 | Migration001(number="001", 28 | migration_data_directory=self.migration_data_directory, 29 | config_folder=self.config_directory, 30 | logs_folder=self.logs_directory), 31 | # Convert old config.yaml (or migration file above) to new config.yaml schema 32 | Migration002(number="002", 33 | migration_data_directory=self.migration_data_directory, 34 | config_folder=self.config_directory, 35 | logs_folder=self.logs_directory), 36 | # Add "Recently Added" webhook support 37 | Migration003(number="003", 38 | migration_data_directory=self.migration_data_directory, 39 | config_folder=self.config_directory, 40 | logs_folder=self.logs_directory), 41 | ] 42 | 43 | def run_migrations(self) -> bool: 44 | for migration in self.migrations: 45 | try: 46 | if not migration.pre_forward_check(): 47 | logging.info(f"Migration {migration.number} skipped") 48 | continue 49 | 50 | migration.forward() 51 | 52 | if not migration.post_forward_check(): 53 | logging.error(f"Migration {migration.number} failed") 54 | return False # Exit early, prevent further migrations 55 | 56 | migration.mark_done() 57 | except Exception as e: 58 | logging.error(f"Migration {migration.number} failed: {e}") 59 | return False # Exit early, prevent further migrations 60 | 61 | return True 62 | -------------------------------------------------------------------------------- /migrations/migration_names.py: -------------------------------------------------------------------------------- 1 | V1_CONFIG_FILE = "config.yaml" 2 | V2_CONFIG_FILE = "tauticord.yaml" 3 | MIGRATION_001_CONFIG_FILE = "001_env_var_to_config_yaml.yaml" 4 | MIGRATION_002_CONFIG_FILE = "002_old_config_to_new_config.yaml" 5 | MIGRATION_003_CONFIG_FILE = "m003_add_recently_added_webhook.yaml" 6 | -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/__init__.py -------------------------------------------------------------------------------- /modules/analytics.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | import uuid 3 | 4 | import objectrest 5 | 6 | 7 | def _time_uuid(): 8 | return uuid.uuid1() 9 | 10 | 11 | def _random_uuid(): 12 | return uuid.uuid4() 13 | 14 | 15 | def _generate_uuid(random=False): 16 | if random: 17 | return _random_uuid() 18 | return _time_uuid() 19 | 20 | 21 | def _verify_params(params_needed, **included_params): 22 | for needed_param in params_needed: 23 | if needed_param not in included_params.keys() or included_params.get(needed_param): 24 | return False 25 | return True 26 | 27 | 28 | def _make_url(params_dict): 29 | base_url = "https://www.google-analytics.com/collect" 30 | args = urllib.parse.urlencode(params_dict) 31 | return f"{base_url}?{args}" 32 | 33 | 34 | class GoogleAnalytics: 35 | def __init__(self, analytics_id: str, anonymous_ip: bool = False, do_not_track: bool = False): 36 | self.analytics_id = analytics_id 37 | self.version = '1' 38 | self.anonymize_ip = anonymous_ip 39 | self.do_not_track = do_not_track 40 | 41 | def _send(self, final_params): 42 | if self.do_not_track: 43 | return True 44 | url = _make_url(params_dict=final_params) 45 | try: 46 | if objectrest.post(url=url): 47 | return True 48 | except: 49 | pass 50 | return False 51 | 52 | def event(self, event_category: str, event_action: str, 53 | event_label: str = None, event_value: int = None, user_id: str = None, 54 | anonymize_ip: bool = False, random_uuid_if_needed: bool = False): 55 | if self.do_not_track: 56 | return True 57 | if not user_id: 58 | user_id = str(_generate_uuid(random=random_uuid_if_needed)) 59 | final_params = {'v': self.version, 'tid': self.analytics_id, 't': 'event', 'cid': user_id} 60 | if anonymize_ip or self.anonymize_ip: 61 | final_params['aip'] = 0 62 | final_params['ec'] = event_category 63 | final_params['ea'] = event_action 64 | if event_label: 65 | final_params['el'] = event_label 66 | if event_value: 67 | final_params['ev'] = event_value 68 | return self._send(final_params=final_params) 69 | 70 | def pageview(self, visited_page: str, 71 | page_title: str = None, user_id: str = None, 72 | anonymize_ip: bool = False, random_uuid_if_needed: bool = False): 73 | if self.do_not_track: 74 | return True 75 | if not user_id: 76 | user_id = str(_generate_uuid(random=random_uuid_if_needed)) 77 | final_params = {'v': self.version, 'tid': self.analytics_id, 't': 'pageview', 'cid': user_id} 78 | if anonymize_ip or self.anonymize_ip: 79 | final_params['aip'] = 0 80 | if not visited_page.startswith('/'): 81 | visited_page = f"/{visited_page}" 82 | final_params['dl'] = visited_page 83 | if page_title: 84 | final_params['dt'] = page_title 85 | return self._send(final_params=final_params) 86 | -------------------------------------------------------------------------------- /modules/charts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections.abc import Callable 3 | from typing import Any 4 | 5 | import matplotlib.pyplot as plt 6 | from matplotlib.axes import Axes 7 | from matplotlib.figure import Figure 8 | 9 | from modules.utils import seconds_to_hours_minutes 10 | 11 | MIN_TEXT_BOTTOM_PADDING = 0 12 | 13 | PLAY_COUNT_FORMATTER = plt.FuncFormatter(lambda x, _: f'{int(x)}') 14 | PLAY_DURATION_FORMATTER = plt.FuncFormatter(lambda x, _: seconds_to_hours_minutes(x)) 15 | 16 | 17 | class ChartMaker: 18 | def __init__(self, 19 | x_axis: list, 20 | x_axis_labels: list[str], 21 | title: str = None, 22 | x_axis_name: str = None, 23 | y_axis_name: str = None, 24 | x_axis_major_formatter=None, 25 | x_axis_minor_formatter=None, 26 | y_axis_major_formatter=None, 27 | y_axis_minor_formatter=None, 28 | y_axis_tick_calculator=None, 29 | text_color: str = None, 30 | background_color: str = None, 31 | grid_line_color: str = None): 32 | self._title: str = title 33 | self._x_axis: list = x_axis 34 | self._x_axis_labels: list[str] = x_axis_labels 35 | self._x_axis_name: str = x_axis_name 36 | self._y_axis_name: str = y_axis_name 37 | self._x_axis_major_formatter = x_axis_major_formatter 38 | self._x_axis_minor_formatter = x_axis_minor_formatter 39 | self._y_axis_major_formatter = y_axis_major_formatter 40 | self._y_axis_minor_formatter = y_axis_minor_formatter 41 | self._y_axis_tick_calculator = y_axis_tick_calculator 42 | self._text_color: str = text_color 43 | self._enable_grid_lines: bool = grid_line_color is not None 44 | 45 | # Set up initial figure and single plot 46 | # TODO: Set minimum dimensions for plot 47 | self._figure: Figure = plt.figure() 48 | self._plot: Axes = self._figure.add_subplot(1, 1, 1) 49 | if background_color: 50 | self._figure.set_facecolor(background_color) 51 | self._plot.set_facecolor(background_color) 52 | if self._enable_grid_lines: 53 | self._plot.yaxis.grid(True, color=grid_line_color, linestyle='-', linewidth=0.5, 54 | zorder=0) # zorder=0 to draw first (behind data lines) 55 | self._plot.tick_params(colors=background_color or None, which='both') 56 | 57 | def save(self, path: str): 58 | # Delay text formatting until after plot is created and data/markers added 59 | 60 | # Hide border/outline around plot 61 | spines = ['top', 'bottom', 'left', 'right'] 62 | for spine in spines: 63 | self._plot.spines[spine].set_visible(False) 64 | 65 | # Set axis labels and title 66 | text_params = { 67 | 'fontweight': 'bold', 68 | 'color': self._text_color 69 | } 70 | if self._x_axis_name: 71 | self._plot.set_xlabel(self._x_axis_name, **text_params) 72 | if self._y_axis_name: 73 | self._plot.set_ylabel(self._y_axis_name, **text_params) 74 | if self._title: 75 | self._plot.set_title(self._title, **text_params) 76 | 77 | # Set axis ticks and labels 78 | self._plot.set_xticks(self._x_axis) 79 | self._plot.set_xticklabels(self._x_axis_labels, rotation=45, ha='right', color=self._text_color) 80 | y_axis_tick_labels = self._plot.get_yticklabels() 81 | self._plot.set_yticklabels(y_axis_tick_labels, color=self._text_color) 82 | if self._y_axis_tick_calculator: 83 | y_min_value, y_max_value = self._plot.get_ylim() 84 | ticks = self._y_axis_tick_calculator(y_min_value, y_max_value) 85 | self._plot.yaxis.set_ticks(ticks) 86 | 87 | # Set formatters and locators for axes 88 | if self._x_axis_major_formatter: 89 | self._plot.xaxis.set_major_formatter(self._x_axis_major_formatter) 90 | if self._x_axis_minor_formatter: 91 | self._plot.xaxis.set_minor_formatter(self._x_axis_minor_formatter) 92 | if self._y_axis_major_formatter: 93 | self._plot.yaxis.set_major_formatter(self._y_axis_major_formatter) 94 | if self._y_axis_minor_formatter: 95 | self._plot.yaxis.set_minor_formatter(self._y_axis_minor_formatter) 96 | 97 | # Add legend 98 | self._plot.legend() 99 | 100 | # Set tight layout 101 | self._figure.set_tight_layout(3) 102 | 103 | # Write the chart to a file 104 | plt.savefig(path) 105 | logging.info(f"Saved chart to {path}") 106 | 107 | def add_line(self, values: list, label: str = None, marker: str = None, line_style: str = None, 108 | line_color: str = None, text_color: str = None, value_text_formatter: Callable[[Any], str] = None): 109 | self._plot.set_axisbelow(True) 110 | kwargs = {} 111 | if label: 112 | kwargs['label'] = label 113 | if marker: 114 | kwargs['marker'] = marker 115 | if line_style: 116 | kwargs['linestyle'] = line_style 117 | if line_color: 118 | kwargs['color'] = line_color 119 | kwargs['markerfacecolor'] = line_color 120 | kwargs['markeredgecolor'] = line_color 121 | 122 | if not value_text_formatter: 123 | def value_text_formatter_function(x: Any) -> str: 124 | return f'{x}' 125 | 126 | value_text_formatter = value_text_formatter_function 127 | 128 | self._plot.plot(self._x_axis, values, **kwargs) 129 | 130 | # Add value text to each point 131 | for i, value in enumerate(values): 132 | if value == 0: 133 | continue 134 | self._plot.text(self._x_axis[i], value + 1, value_text_formatter(value), ha='center', 135 | va='bottom', fontsize=8, 136 | color=text_color or self._text_color) 137 | 138 | def add_stacked_bar(self, values: list[list], labels: list[str], bar_width: float = 0.8, 139 | bar_colors: list[str] = None, text_color: str = None, 140 | value_text_formatter: Callable[[Any], str] = None): 141 | self._plot.set_axisbelow(True) 142 | bottom = [0] * len(self._x_axis) 143 | 144 | if not value_text_formatter: 145 | def value_text_formatter_function(x: Any) -> str: 146 | return f'{x}' 147 | 148 | value_text_formatter = value_text_formatter_function 149 | 150 | for i, value in enumerate(values): 151 | kwargs = {} 152 | if bar_colors: 153 | kwargs['color'] = bar_colors[i] 154 | self._plot.bar(self._x_axis, value, width=bar_width, label=labels[i], bottom=bottom, **kwargs) 155 | bottom = [sum(x) for x in zip(bottom, value)] 156 | # Add value text to each bar 157 | for j, sub_value in enumerate(value): 158 | if sub_value == 0: 159 | continue 160 | self._plot.text(self._x_axis[j], max(bottom[j] - 1.5, MIN_TEXT_BOTTOM_PADDING), 161 | value_text_formatter(sub_value), 162 | ha='center', va='bottom', fontsize=8, 163 | color=text_color or self._text_color) 164 | -------------------------------------------------------------------------------- /modules/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/database/__init__.py -------------------------------------------------------------------------------- /modules/database/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/database/base/__init__.py -------------------------------------------------------------------------------- /modules/database/base/base.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import List 3 | 4 | from sqlalchemy import create_engine, MetaData, null 5 | from sqlalchemy.orm import sessionmaker 6 | from sqlalchemy_utils import database_exists, create_database 7 | 8 | 9 | def no_none(func): 10 | @wraps(func) 11 | def wrapper(self, *args, **kwargs): 12 | """ 13 | Throw exception if any values are None 14 | """ 15 | func(self, *args, **kwargs) 16 | for k, v in self.__dict__.items(): 17 | if v is None: 18 | raise Exception(f"None value for {k}") 19 | 20 | return wrapper 21 | 22 | 23 | def none_as_null(func): 24 | @wraps(func) 25 | def wrapper(self, *args, **kwargs): 26 | """ 27 | Replace None as null() 28 | """ 29 | func(self, *args, **kwargs) 30 | for k, v in self.__dict__.items(): 31 | if v is None: 32 | setattr(self, k, null()) 33 | 34 | return wrapper 35 | 36 | 37 | def map_attributes(func): 38 | @wraps(func) 39 | def wrapper(self, *args, **kwargs): 40 | """ 41 | Map kwargs to class attributes 42 | """ 43 | func(self, *args, **kwargs) 44 | for k, v in kwargs.items(): 45 | if getattr(self, k): 46 | setattr(self, k, v) 47 | 48 | return wrapper 49 | 50 | 51 | def false_if_error(func): 52 | @wraps(func) 53 | def wrapper(self, *args, **kwargs): 54 | """ 55 | Return False if error encountered in function, instead of raising exception 56 | """ 57 | try: 58 | return func(self, *args, **kwargs) 59 | except Exception as e: 60 | return False 61 | 62 | return wrapper 63 | 64 | 65 | class SQLAlchemyDatabase: 66 | def __init__(self, 67 | sqlite_file: str): 68 | self.sqlite_file = sqlite_file 69 | 70 | self.engine = None 71 | self.session = None 72 | 73 | self.url = f'sqlite:///{sqlite_file}' 74 | 75 | self._setup() 76 | 77 | def _commit(self): 78 | try: 79 | self.session.commit() 80 | except Exception as e: 81 | self.session.rollback() 82 | raise Exception(f"Failed to commit session: {e}") from e 83 | 84 | def _rollback(self): 85 | try: 86 | self.session.rollback() 87 | except Exception as e: 88 | raise Exception(f"Failed to rollback session: {e}") from e 89 | 90 | def _close(self): 91 | self.session.close() 92 | 93 | def _setup(self): 94 | if not self.url: 95 | return 96 | 97 | self.engine = create_engine(self.url) 98 | 99 | if not self.engine: 100 | return 101 | 102 | if not database_exists(self.engine.url): 103 | create_database(self.engine.url) 104 | 105 | MetaData().create_all(self.engine) 106 | 107 | _session = sessionmaker() 108 | _session.configure(bind=self.engine) 109 | self.session = _session() 110 | 111 | def _build_query(self, table_schema, *filters): 112 | query = self.session.query(table_schema) 113 | if filters: 114 | query = query.filter(*filters) 115 | return query 116 | 117 | def _get_first_entry(self, table_schema, *filters) -> None | object: 118 | query = self._build_query(table_schema, *filters) 119 | 120 | return query.first() 121 | 122 | def _get_all_entries(self, table_schema, *filters) -> List[object]: 123 | query = self._build_query(table_schema, *filters) 124 | 125 | return query.all() 126 | 127 | def _get_attribute_from_first_entry(self, table_schema, field_name, *filters) -> None | object: 128 | entry = self._get_first_entry(table_schema=table_schema, *filters) 129 | 130 | return getattr(entry, field_name, None) 131 | 132 | def _get_attribute_from_all_entries(self, table_schema, field_name, *filters) -> List[object]: 133 | entries = self._get_all_entries(table_schema=table_schema, *filters) 134 | 135 | return [getattr(entry, field_name, None) for entry in entries] 136 | 137 | def _set_attribute_of_first_entry(self, table_schema, field_name, field_value, *filters) -> bool: 138 | entry = self._get_first_entry(table_schema=table_schema, *filters) 139 | 140 | if not entry: 141 | entry = self._create_entry(table_schema, **{field_name: field_value}) 142 | 143 | return self._update_entry_single_field(entry, field_name, field_value) 144 | 145 | def _set_attribute_of_all_entries(self, table_schema, field_name, field_value, *filters) -> bool: 146 | entries = self._get_all_entries(table_schema=table_schema, *filters) 147 | 148 | if not entries: 149 | entries = [self._create_entry(table_schema, **{field_name: field_value})] 150 | 151 | return all([self._update_entry_single_field(entry, field_name, field_value) for entry in entries]) 152 | 153 | @false_if_error 154 | def _create_entry(self, table_schema, **kwargs) -> object: 155 | entry = table_schema(**kwargs) 156 | self.session.add(entry) 157 | self._commit() 158 | return entry 159 | 160 | @false_if_error 161 | def _create_entry_if_does_not_exist(self, table_schema, fields_to_check: List[str], **kwargs) -> object: 162 | filters = {k: v for k, v in kwargs.items() if k in fields_to_check} 163 | entries = self._get_all_entries(table_schema=table_schema, *filters) 164 | if not entries: 165 | return self._create_entry(table_schema=table_schema, **kwargs) 166 | return entries[0] 167 | 168 | def _create_entry_fail_if_exists(self, table_schema, fields_to_check: List[str], **kwargs) -> object: 169 | filters = {k: v for k, v in kwargs.items() if k in fields_to_check} 170 | entries = self._get_all_entries(table_schema=table_schema, *filters) 171 | # TODO: Different exception types (duplicate record vs connection error), parse downstream 172 | if entries: 173 | raise Exception(f"Entry already exists for {kwargs}") 174 | return self._create_entry(table_schema=table_schema, **kwargs) 175 | 176 | @false_if_error 177 | def _update_entry_single_field(self, entry, field_name, field_value) -> bool: 178 | setattr(entry, field_name, field_value) 179 | self._commit() 180 | return True 181 | 182 | @false_if_error 183 | def _update_entry_multiple_fields(self, entry, **kwargs) -> bool: 184 | for field, value in kwargs.items(): 185 | setattr(entry, field, value) 186 | self._commit() 187 | return True 188 | 189 | 190 | class CustomTable: 191 | def __init__(self): 192 | self._ignore = [] 193 | -------------------------------------------------------------------------------- /modules/database/base/imports.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import ( 2 | Integer, 3 | BigInteger, 4 | Column, 5 | Enum, 6 | Index, 7 | String, 8 | Table, 9 | null, 10 | Boolean, 11 | VARCHAR as VarChar, 12 | ForeignKey, 13 | ) 14 | from sqlalchemy.ext.declarative import declarative_base 15 | 16 | Base = declarative_base() 17 | -------------------------------------------------------------------------------- /modules/database/base/utils.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from sqlalchemy.orm import DeclarativeMeta 4 | 5 | from modules.database.base.imports import * 6 | from modules.utils import convert_string_to_bool 7 | 8 | Base = declarative_base() 9 | 10 | 11 | def get_table_schema_name(table: DeclarativeMeta) -> str: 12 | return getattr(table, "__name__", None) 13 | 14 | 15 | def get_table_columns(table: Table) -> List[Column]: 16 | return table.columns._all_columns 17 | 18 | 19 | def get_table_column_names(table: Table) -> List[str]: 20 | columns = get_table_columns(table=table) 21 | return [column.name for column in columns] 22 | 23 | 24 | def table_schema_to_name_type_pairs(table: Table): 25 | columns = get_table_columns(table=table) 26 | pairs = {} 27 | ignore_columns = getattr(table, "_ignore", []) 28 | for column in columns: 29 | if column not in ignore_columns: 30 | pairs[column.name] = sql_type_to_human_type_string(column.type) 31 | return pairs 32 | 33 | 34 | def sql_type_to_human_type_string(sql_type) -> str: 35 | if not hasattr(sql_type, "python_type"): 36 | return "" 37 | 38 | python_type = sql_type.python_type 39 | if python_type == str: 40 | return "String" 41 | elif python_type in [int, float]: 42 | return "Number" 43 | elif python_type == bool: 44 | return "True/False" 45 | return "" 46 | 47 | 48 | def human_type_to_python_type(human_type: str): 49 | try: 50 | return float(human_type) # is it a float? 51 | except: 52 | try: 53 | return int(human_type) # is it an int? 54 | except: 55 | bool_value = convert_string_to_bool(bool_string=human_type) 56 | if bool_value is not None: # is it a bool? 57 | return bool_value 58 | else: 59 | return human_type # it's a string 60 | -------------------------------------------------------------------------------- /modules/database/database.py: -------------------------------------------------------------------------------- 1 | from tautulli.tools.webhooks import TautulliWebhookTrigger 2 | 3 | import modules.database.base.base as db 4 | from modules.database.models.recently_added_item import RecentlyAddedItem 5 | from modules.database.models.version import Version 6 | from modules.database.models.webhooks import Webhook 7 | from modules.utils import get_minutes_ago_timestamp 8 | 9 | 10 | class RootDatabase(db.SQLAlchemyDatabase): 11 | def __init__(self, 12 | sqlite_file: str): 13 | super().__init__(sqlite_file=sqlite_file) 14 | Version.__table__.create(bind=self.engine, checkfirst=True) 15 | Webhook.__table__.create(bind=self.engine, checkfirst=True) 16 | RecentlyAddedItem.__table__.create(bind=self.engine, checkfirst=True) 17 | 18 | # Version table 19 | 20 | def get_version(self) -> Version: 21 | """ 22 | Get the version of the database 23 | :return: The version of the database 24 | """ 25 | try: 26 | return self._get_first_entry(Version) # type: ignore 27 | except Exception as e: 28 | raise Exception("Failed to get version from database") from e 29 | 30 | def set_version(self, semver: str) -> bool: 31 | """ 32 | Set the version of the database 33 | :param semver: The version to set 34 | :type semver: str 35 | :return: True if the version was set, False otherwise 36 | """ 37 | try: 38 | entry = self.get_version() 39 | if not entry: 40 | return self._create_entry(table_schema=Version, semver=semver) is not None # type: ignore 41 | else: 42 | return self._update_entry_single_field(entry=entry, field_name="semver", field_value=semver) # type: ignore 43 | except Exception as e: 44 | raise Exception("Failed to set version in database") from e 45 | 46 | # Webhooks table 47 | 48 | def add_webhook(self, webhook_type: TautulliWebhookTrigger) -> None | Webhook: 49 | """ 50 | Add a webhook to the database 51 | :param webhook_type: The type of webhook to add 52 | :type webhook_type: TautulliWebhookTrigger 53 | :return: The webhook that was added 54 | """ 55 | try: 56 | return self._create_entry(table_schema=Webhook, webhook_type=webhook_type) # type: ignore 57 | except Exception as e: 58 | raise Exception("Failed to add webhook to database") from e 59 | 60 | def get_all_webhooks_by_type(self, webhook_type: TautulliWebhookTrigger) -> list[Webhook]: 61 | """ 62 | Get all webhooks by type 63 | :param webhook_type: The type of webhook to get 64 | :type webhook_type: TautulliWebhookTrigger 65 | :return: A list of webhooks of the specified type 66 | """ 67 | try: 68 | return self._get_all_entries(Webhook,(Webhook.webhook_type == webhook_type)) # type: ignore 69 | except Exception as e: 70 | raise Exception("Failed to get webhooks from database") from e 71 | 72 | def get_all_webhooks_by_time(self, minutes: int) -> list[Webhook]: 73 | """ 74 | Get all webhooks by time 75 | :param minutes: The number of minutes to look back 76 | :type minutes: int 77 | :return: A list of webhooks received in the past x minutes 78 | """ 79 | timestamp = get_minutes_ago_timestamp(minutes=minutes) 80 | try: 81 | return self._get_all_entries(Webhook, (Webhook.created_at >= timestamp)) # type: ignore 82 | except Exception as e: 83 | raise Exception("Failed to get webhooks from database") from e 84 | 85 | def get_all_webhooks_by_type_and_time(self, webhook_type: TautulliWebhookTrigger, minutes: int) -> list[Webhook]: 86 | """ 87 | Get all webhooks by type and time 88 | :param webhook_type: The type of webhook to get 89 | :type webhook_type: TautulliWebhookTrigger 90 | :param minutes: The number of minutes to look back 91 | :type minutes: int 92 | :return: A list of webhooks of the specified type received in the past x minutes 93 | """ 94 | timestamp = get_minutes_ago_timestamp(minutes=minutes) 95 | try: 96 | return self._get_all_entries(Webhook, 97 | (Webhook.webhook_type == webhook_type), 98 | (Webhook.created_at >= timestamp)) # type: ignore 99 | except Exception as e: 100 | raise Exception("Failed to get webhooks from database") from e 101 | 102 | # RecentlyAddedItem table 103 | 104 | def add_recently_added_item(self, name: str, library_name: str, webhook_id: int) -> None | RecentlyAddedItem: 105 | """ 106 | Add a recently added item to the database 107 | :param name: The name of the item 108 | :type name: str 109 | :param library_name: The name of the library 110 | :type library_name: str 111 | :param webhook_id: The ID of the webhook 112 | :type webhook_id: int 113 | :return: The recently added item that was added 114 | """ 115 | try: 116 | return self._create_entry(table_schema=RecentlyAddedItem, 117 | name=name, 118 | library_name=library_name, 119 | webhook_id=webhook_id) # type: ignore 120 | except Exception as e: 121 | raise Exception("Failed to add recently added item to database") from e 122 | 123 | def get_all_recently_added_items_in_past_x_minutes(self, minutes: int) -> list[RecentlyAddedItem]: 124 | """ 125 | Get all recently added items received in the past x minutes 126 | :param minutes: The number of minutes to look back 127 | :type minutes: int 128 | :return: A list of recently added items received in the past x minutes 129 | """ 130 | timestamp = get_minutes_ago_timestamp(minutes=minutes) 131 | try: 132 | return self._get_all_entries(RecentlyAddedItem, (RecentlyAddedItem.created_at >= timestamp)) # type: ignore 133 | except Exception as e: 134 | raise Exception("Failed to get recently added items from database") from e 135 | 136 | def get_all_recently_added_items_in_past_x_minutes_for_libraries(self, minutes: int, library_names: list[str]) -> list[RecentlyAddedItem]: 137 | """ 138 | Get all recently added items in the past x minutes for specific libraries 139 | :param minutes: The number of minutes to look back 140 | :type minutes: int 141 | :param library_names: The names of the libraries to filter by 142 | :type library_names: list[str] 143 | :return: A list of recently added items in the past x minutes for specific libraries 144 | """ 145 | timestamp = get_minutes_ago_timestamp(minutes=minutes) 146 | try: 147 | return self._get_all_entries(RecentlyAddedItem, 148 | (RecentlyAddedItem.created_at >= timestamp), 149 | (RecentlyAddedItem.library_name.in_(library_names))) # type: ignore 150 | except Exception as e: 151 | raise Exception("Failed to get recently added items from database") from e 152 | -------------------------------------------------------------------------------- /modules/database/migrations.py: -------------------------------------------------------------------------------- 1 | from packaging.version import Version as PackagingVersion, parse as parse_version 2 | 3 | import modules.database.repository as db 4 | 5 | 6 | def initial_setup(database: db.DatabaseRepository): 7 | # Nothing needed for initial setup, will automatically create tables 8 | pass 9 | 10 | 11 | MIGRATIONS = { 12 | # Above this version -> run migration 13 | "5.8.0": initial_setup, 14 | } 15 | 16 | 17 | def run_migrations(database_path: str) -> bool: 18 | database = db.DatabaseRepository(database_path=database_path) 19 | 20 | current_version = "0.0.0" 21 | try: 22 | current_version = database.get_database_version() 23 | except Exception as e: 24 | pass 25 | current_version = parse_version(version=current_version) 26 | 27 | for target_version_string, migration_function in MIGRATIONS.items(): 28 | target_version: PackagingVersion = parse_version(version=target_version_string) 29 | if current_version < target_version: 30 | migration_function(database) 31 | if not database.set_database_version(version=target_version_string): 32 | print(f"Failed to set database version to {target_version_string}") 33 | return False 34 | 35 | print(f"Migration to version {target_version_string} complete") 36 | 37 | return True 38 | -------------------------------------------------------------------------------- /modules/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | 3 | Base = declarative_base() 4 | -------------------------------------------------------------------------------- /modules/database/models/recently_added_item.py: -------------------------------------------------------------------------------- 1 | from modules.database.base.imports import * 2 | 3 | from modules.utils import get_now_timestamp 4 | 5 | 6 | class RecentlyAddedItem(Base): 7 | __tablename__ = 'recently_added_items' 8 | id = Column("id", Integer, primary_key=True, autoincrement=True, nullable=False) 9 | name = Column("name", String, nullable=False) 10 | library_name = Column("library_name", String, nullable=False) 11 | webhook_id = Column("webhook_id", Integer, ForeignKey('webhooks.id'), nullable=False) 12 | created_at = Column("created_at", BigInteger, nullable=False, default=get_now_timestamp) 13 | updated_at = Column("updated_at", BigInteger, nullable=False, default=get_now_timestamp, onupdate=get_now_timestamp) 14 | 15 | def __init__(self, name: str, library_name: str, webhook_id: int, **kwargs): 16 | self.name = name 17 | self.library_name = library_name 18 | self.webhook_id = webhook_id 19 | self.created_at = get_now_timestamp() 20 | self.updated_at = get_now_timestamp() 21 | -------------------------------------------------------------------------------- /modules/database/models/version.py: -------------------------------------------------------------------------------- 1 | from modules.database.base.imports import * 2 | 3 | 4 | class Version(Base): 5 | __tablename__ = 'version' 6 | semver = Column("semver", String, primary_key=True, nullable=False) 7 | 8 | def __init__(self, semver: str, **kwargs): 9 | self.semver = semver 10 | -------------------------------------------------------------------------------- /modules/database/models/webhooks.py: -------------------------------------------------------------------------------- 1 | from modules.database.base.imports import * 2 | from modules.utils import get_now_timestamp 3 | 4 | from tautulli.tools.webhooks import TautulliWebhookTrigger 5 | 6 | 7 | class Webhook(Base): 8 | __tablename__ = 'webhooks' 9 | id = Column("id", Integer, primary_key=True, autoincrement=True, nullable=False) 10 | webhook_type = Column("webhook_type", Enum(TautulliWebhookTrigger), nullable=False) 11 | created_at = Column("created_at", BigInteger, nullable=False, default=get_now_timestamp) 12 | updated_at = Column("updated_at", BigInteger, nullable=False, default=get_now_timestamp, onupdate=get_now_timestamp) 13 | 14 | def __init__(self, webhook_type: TautulliWebhookTrigger, **kwargs): 15 | self.webhook_type = webhook_type 16 | self.created_at = get_now_timestamp() 17 | self.updated_at = get_now_timestamp() 18 | -------------------------------------------------------------------------------- /modules/database/repository.py: -------------------------------------------------------------------------------- 1 | from tautulli.tools.webhooks import TautulliWebhook 2 | 3 | from modules.database.database import RootDatabase 4 | from modules.database.models.recently_added_item import RecentlyAddedItem as RecentlyAddedItemDatabaseModel 5 | from modules.database.models.version import Version as VersionDatabaseModel 6 | from modules.database.models.webhooks import Webhook as WebhookDatabaseModel 7 | from modules.models import RecentlyAddedItem as RecentlyAddedItemModel, Webhook as WebhookModel 8 | from modules.webhooks import RecentlyAddedWebhook, RecentlyAddedWebhookData 9 | 10 | """ 11 | NOTES: 12 | - `database.py` deals with raw database records and ORM models. 13 | - `repository.py` translates database records/query results into in-memory models. 14 | - No function calling a repository function should ever receive back a raw database record. 15 | """ 16 | 17 | 18 | class DatabaseRepository: 19 | def __init__(self, database_path: str): 20 | self._database = RootDatabase(sqlite_file=database_path) 21 | 22 | def get_database_version(self) -> str: 23 | """ 24 | Get the version of the database 25 | 26 | :return: The version of the database 27 | """ 28 | try: 29 | version: VersionDatabaseModel = self._database.get_version() 30 | return version.semver 31 | except Exception as e: 32 | raise Exception(f'Error getting database version: {e}') from e 33 | 34 | def set_database_version(self, version: str) -> bool: 35 | """ 36 | Set the version of the database 37 | 38 | :param version: The version to set 39 | :type version: str 40 | :return: True if the version was set, False otherwise 41 | """ 42 | try: 43 | return self._database.set_version(semver=version) 44 | except Exception as e: 45 | raise Exception(f'Error setting database version: {e}') from e 46 | 47 | def add_received_recently_added_webhook_to_database(self, webhook: RecentlyAddedWebhook) -> WebhookModel | None: 48 | """ 49 | Add a received recently-added webhook to the database 50 | 51 | :param webhook: The webhook to add 52 | :type webhook: TautulliWebhook 53 | :return: The webhook that was added, or None if the webhook could not be added 54 | """ 55 | try: 56 | database_entry: WebhookDatabaseModel = self._database.add_webhook(webhook_type=webhook.trigger) 57 | data: RecentlyAddedWebhookData = webhook.data # type: ignore 58 | library_name = data.library_name 59 | item_name = data.title 60 | _ = self.add_recently_added_item_to_database(webhook=database_entry, 61 | library_name=library_name, 62 | item_name=item_name) 63 | 64 | return WebhookModel.from_database_record(record=database_entry) 65 | except Exception as e: 66 | raise Exception(f'Error adding webhook to database: {e}') from e 67 | 68 | def add_recently_added_item_to_database(self, webhook: WebhookDatabaseModel, library_name: str, 69 | item_name: str) -> RecentlyAddedItemModel | None: 70 | """ 71 | Add a "recently added" item to the database 72 | 73 | :param webhook: The webhook that triggered the item 74 | :type webhook: Webhook 75 | :param library_name: The name of the library the item was added to 76 | :type library_name: str 77 | :param item_name: The name of the item that was added 78 | :type item_name: str 79 | :return: The "recently added" item that was added, or None if the item could not be added 80 | """ 81 | try: 82 | database_entry: RecentlyAddedItemDatabaseModel = ( 83 | self._database.add_recently_added_item( 84 | name=item_name, 85 | library_name=library_name, 86 | webhook_id=webhook.id) 87 | ) 88 | return RecentlyAddedItemModel.from_database_record(record=database_entry) 89 | except Exception as e: 90 | raise Exception(f'Error adding recently added item to database: {e}') from e 91 | 92 | def get_all_recently_added_items_in_past_x_minutes_for_libraries(self, minutes: int, library_names: list[str]) -> \ 93 | list[RecentlyAddedItemModel]: 94 | """ 95 | Get all "recently added" items in the past x minutes for specific libraries 96 | 97 | :param minutes: The number of minutes to look back 98 | :type minutes: int 99 | :param library_names: The names of the libraries to filter by 100 | :type library_names: list[str] 101 | :return: A list of "recently added" items in the past x minutes for specific libraries 102 | """ 103 | try: 104 | database_entries: list[RecentlyAddedItemDatabaseModel] = ( 105 | self._database.get_all_recently_added_items_in_past_x_minutes_for_libraries( 106 | minutes=minutes, 107 | library_names=library_names) 108 | ) 109 | return [RecentlyAddedItemModel.from_database_record(record=entry) for entry in database_entries] 110 | except Exception as e: 111 | raise Exception(f'Error getting webhooks from database: {e}') from e 112 | -------------------------------------------------------------------------------- /modules/discord/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/discord/__init__.py -------------------------------------------------------------------------------- /modules/discord/bot.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | import modules.logs as logging 5 | import modules.settings.models as settings_models 6 | import modules.statics as statics 7 | from modules.discord import discord_utils 8 | from modules.discord.services.base_service import BaseService 9 | from modules.emojis import EmojiManager 10 | 11 | 12 | class Bot: 13 | def __init__(self, 14 | bot_token: str, 15 | services: list[BaseService], 16 | discord_status_settings: settings_models.DiscordStatusMessage, 17 | guild_id: int, 18 | emoji_manager: EmojiManager): 19 | self._token = bot_token 20 | self._services = services 21 | self.discord_status_settings = discord_status_settings 22 | self.guild_id = guild_id 23 | self.emoji_manager = emoji_manager 24 | 25 | intents = discord.Intents.default() 26 | intents.reactions = True # Required for on_raw_reaction_add 27 | intents.message_content = True # Required for slash commands 28 | self.client = commands.Bot(command_prefix=statics.BOT_PREFIX, 29 | intents=intents) # Need a Bot (subclass of Client) for cogs to work 30 | 31 | self.on_ready = self.client.event(self.on_ready) 32 | 33 | def connect(self) -> None: 34 | # TODO: Look at merging loggers 35 | self.client.run(self._token, reconnect=True) # Can't have any asyncio tasks running before the bot is ready 36 | 37 | async def on_ready(self): 38 | # Set bot status if required 39 | if self.discord_status_settings.should_update_on_startup: 40 | logging.debug("Setting bot status...") 41 | activity_name = self.discord_status_settings.activity_name 42 | message = self.discord_status_settings.message( 43 | stream_count=0, # No streams on startup, use fallback 44 | fallback="Starting up...") 45 | await discord_utils.update_presence(client=self.client, 46 | activity_name=activity_name, 47 | line_one=message) 48 | 49 | logging.debug("Uploading required resources...") 50 | 51 | # How many emoji slots are left (excluding any emojis that have already been uploaded; avoid re-uploading) 52 | available_emoji_slots = await discord_utils.available_emoji_slots(client=self.client, guild_id=self.guild_id) 53 | un_uploaded_emoji_count = len(await self.emoji_manager.get_un_uploaded_emoji_files( 54 | client=self.client, guild_id=self.guild_id)) 55 | 56 | if un_uploaded_emoji_count > 0: 57 | if available_emoji_slots < un_uploaded_emoji_count: 58 | logging.fatal( 59 | f"Insufficient emoji slots to upload {un_uploaded_emoji_count} custom emojis, will use built-in emojis instead.") 60 | else: 61 | await self.emoji_manager.load_custom_emojis(client=self.client, guild_id=self.guild_id) 62 | available_emoji_slots -= un_uploaded_emoji_count 63 | 64 | # Activate services 65 | for service in self._services: 66 | await service.register_bot(bot=self.client) 67 | await service.associate_bot_callbacks() 68 | if await service.enabled(): 69 | await service.on_ready() 70 | -------------------------------------------------------------------------------- /modules/discord/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from modules.discord.commands.most import Most 2 | from modules.discord.commands.summary import Summary 3 | from modules.discord.commands.recently import Recently 4 | from modules.discord.commands.graphs import Graphs 5 | from modules.discord.commands._base import ( 6 | respond_thinking, 7 | ) 8 | -------------------------------------------------------------------------------- /modules/discord/commands/_base.py: -------------------------------------------------------------------------------- 1 | import discord 2 | 3 | 4 | async def respond_thinking(interaction: discord.Interaction, ephemeral: bool = True) -> None: 5 | await interaction.response.defer(thinking=True, ephemeral=ephemeral) 6 | -------------------------------------------------------------------------------- /modules/discord/commands/recently.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Callable 2 | 3 | import discord 4 | from discord import app_commands 5 | from discord.ext import commands 6 | 7 | import modules.logs as logging 8 | from modules.discord.commands._base import ( 9 | respond_thinking, 10 | ) 11 | from modules.discord.views import ( 12 | ButtonColor, 13 | EmbedColor, 14 | PaginatedViewStyle, 15 | PaginatedCardView, 16 | PaginatedCardViewItem 17 | ) 18 | from modules.tautulli.tautulli_connector import ( 19 | TautulliConnector, 20 | RecentlyAddedMediaItem, 21 | ) 22 | 23 | 24 | def _build_top_media_response_embed(stats: list[dict[str, str]], title: str) -> discord.Embed: 25 | embed = discord.Embed(title=title, color=EmbedColor.DARK_ORANGE.value) 26 | for stat in stats: 27 | embed.add_field(name=stat["title"], value=stat["total_plays"], inline=False) 28 | return embed 29 | 30 | 31 | def _build_top_user_response_embed(stats: list[dict[str, str]], title: str) -> discord.Embed: 32 | embed = discord.Embed(title=title, color=EmbedColor.DARK_ORANGE.value) 33 | for stat in stats: 34 | embed.add_field(name=stat["friendly_name"], value=stat["total_plays"], inline=False) 35 | return embed 36 | 37 | 38 | class RecentlyAddedMediaItemCard(PaginatedCardViewItem): 39 | def __init__(self, item: RecentlyAddedMediaItem): 40 | self._item = item 41 | 42 | def render(self) -> discord.Embed: 43 | embed = discord.Embed( 44 | title=self._item.title, 45 | color=EmbedColor.DARK_ORANGE.value) 46 | 47 | summary = self._item.summary 48 | if not summary: 49 | summary = "No summary available." 50 | if len(summary) > 1024: 51 | summary = summary[:1021] + "..." 52 | embed.add_field(name="Summary", value=summary, inline=False) 53 | 54 | embed.add_field(name="Available In", value=self._item.library, inline=False) 55 | embed.add_field(name=f"Watch Now", value=f"[Open in Plex Web]({self._item.link})", inline=False) 56 | 57 | embed.set_image(url=self._item.poster_url) 58 | 59 | return embed 60 | 61 | 62 | class Recently(commands.GroupCog, name="recently"): 63 | def __init__(self, bot: commands.Bot, tautulli: TautulliConnector, admin_check: Callable[[discord.Interaction], bool] = None): 64 | self.bot = bot 65 | self._tautulli = tautulli 66 | self._admin_check = admin_check 67 | super().__init__() # This is required for the cog to work. 68 | logging.debug("Recently cog loaded.") 69 | 70 | @app_commands.command(name="added", description="Show recently added media.") 71 | @app_commands.describe( 72 | media_type="The type of media to filter by.", 73 | share="Whether to make the response visible to the channel." 74 | ) 75 | @app_commands.rename(media_type="media-type") 76 | @app_commands.choices( 77 | media_type=[ 78 | discord.app_commands.Choice(name="Movies", value="movie"), 79 | discord.app_commands.Choice(name="Shows", value="show"), 80 | ] 81 | ) 82 | async def added(self, 83 | interaction: discord.Interaction, 84 | media_type: Optional[discord.app_commands.Choice[str]] = None, 85 | share: Optional[bool] = False) -> None: 86 | # This command is public, no admin restrictions 87 | 88 | # Defer the response to give more than the default 3 seconds to respond. 89 | await respond_thinking(interaction=interaction, ephemeral=not share) 90 | 91 | limit = 5 92 | if media_type: 93 | media_type = media_type.value 94 | 95 | items = self._tautulli.get_recently_added_media(count=limit, media_type=media_type) 96 | if not items: 97 | await interaction.response.send_message("No recently added items found.", ephemeral=True) 98 | return 99 | 100 | cards = [RecentlyAddedMediaItemCard(item=item) for item in items] 101 | 102 | style = PaginatedViewStyle() 103 | style.to_beginning_button_color = ButtonColor.GREEN 104 | style.to_end_button_color = ButtonColor.GREEN 105 | style.previous_button_color = ButtonColor.BLURPLE 106 | style.next_button_color = ButtonColor.BLURPLE 107 | 108 | paginated_view = PaginatedCardView(cards=cards, title="Recently Added Media", style=style) 109 | 110 | await paginated_view.send(interaction=interaction, ephemeral=not share) 111 | -------------------------------------------------------------------------------- /modules/discord/commands/summary.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Callable 2 | 3 | import discord 4 | from discord import app_commands 5 | from discord.ext import commands 6 | 7 | import modules.logs as logging 8 | from modules.emojis import EmojiManager 9 | from modules.tautulli.tautulli_connector import ( 10 | TautulliConnector, 11 | ) 12 | 13 | 14 | class Summary(commands.Cog): 15 | def __init__(self, bot: commands.Bot, tautulli: TautulliConnector, emoji_manager: EmojiManager, admin_check: Callable[[discord.Interaction], bool] = None): 16 | self.bot = bot 17 | self._tautulli = tautulli 18 | self._emoji_manager = emoji_manager 19 | self._admin_check = admin_check 20 | super().__init__() # This is required for the cog to work. 21 | logging.debug("Summary cog loaded.") 22 | 23 | async def check_admin(self, interaction: discord.Interaction) -> bool: 24 | if self._admin_check and not self._admin_check(interaction): 25 | await interaction.response.send_message("You do not have permission to use this command.", ephemeral=True) 26 | return False 27 | 28 | return True 29 | 30 | @app_commands.command(name="summary", description="Show current activity summary.") 31 | @app_commands.describe( 32 | share="Whether to make the response visible to the channel." 33 | ) 34 | async def summary(self, interaction: discord.Interaction, share: Optional[bool] = False) -> None: 35 | if not await self.check_admin(interaction): 36 | return 37 | 38 | # Does NOT include new version reminder or stream termination. 39 | summary = self._tautulli.refresh_data(enable_stream_termination_if_possible=False, emoji_manager=self._emoji_manager) 40 | await interaction.response.send_message(embed=summary.embed, ephemeral=not share) 41 | -------------------------------------------------------------------------------- /modules/discord/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/discord/models/__init__.py -------------------------------------------------------------------------------- /modules/discord/models/tautulli_activity_summary.py: -------------------------------------------------------------------------------- 1 | from typing import Union, List 2 | 3 | import discord 4 | 5 | from modules.discord.models.tautulli_stream_info import TautulliStreamInfo 6 | from modules.emojis import EmojiManager 7 | from modules.tautulli.models.activity import Activity 8 | from modules.text_manager import TextManager 9 | 10 | 11 | class TautulliActivitySummary: 12 | def __init__(self, 13 | activity: Union[Activity, None], 14 | plex_online: bool, 15 | server_name: str, 16 | emoji_manager: EmojiManager, 17 | text_manager: TextManager, 18 | streams: List[TautulliStreamInfo] = None, 19 | error_occurred: bool = False, 20 | additional_embed_fields: List[dict] = None, 21 | additional_embed_footers: List[str] = None): 22 | self.activity = activity 23 | self.plex_online = plex_online 24 | self.error_occurred = error_occurred 25 | self.additional_embed_fields = additional_embed_fields or [] 26 | self.additional_embed_footers = additional_embed_footers or [] 27 | self.streams = streams or [] 28 | self._emoji_manager = emoji_manager 29 | self._server_name = server_name 30 | self._text_manager = text_manager 31 | 32 | @property 33 | def embed(self) -> discord.Embed: 34 | title = f"Current activity on {self._server_name}" 35 | if len(self.streams) <= 0: 36 | title = "No current activity" 37 | 38 | embed = discord.Embed(title=title) 39 | 40 | for stream in self.streams: 41 | embed.add_field(name=stream.get_title(emoji_manager=self._emoji_manager, text_manager=self._text_manager), 42 | value=stream.get_body(emoji_manager=self._emoji_manager, text_manager=self._text_manager), 43 | inline=False) 44 | for field in self.additional_embed_fields: 45 | embed.add_field(name=field['name'], value=field['value'], inline=False) 46 | 47 | footer_text = self._text_manager.overview_footer(no_connection=self.error_occurred, 48 | activity=self.activity, 49 | emoji_manager=self._emoji_manager) 50 | if self.additional_embed_footers: 51 | footer_text += "\n" 52 | for additional_footer in self.additional_embed_footers: 53 | footer_text += "\n" + additional_footer 54 | 55 | embed.set_footer(text=footer_text) 56 | 57 | embed.timestamp = discord.utils.utcnow() 58 | 59 | return embed 60 | -------------------------------------------------------------------------------- /modules/discord/models/tautulli_stream_info.py: -------------------------------------------------------------------------------- 1 | import modules.logs as logging 2 | from modules.emojis import EmojiManager 3 | from modules.tautulli.models.session import Session 4 | from modules.text_manager import TextManager 5 | 6 | 7 | class TautulliStreamInfo: 8 | def __init__(self, session: Session, session_number: int): 9 | self._session = session 10 | self._session_number = session_number 11 | 12 | def get_title(self, emoji_manager: EmojiManager, text_manager: TextManager) -> str: 13 | try: 14 | return text_manager.session_title(session=self._session, session_number=self._session_number, 15 | emoji_manager=emoji_manager) 16 | except Exception as title_exception: 17 | logging.error(str(title_exception)) 18 | return "Unknown" 19 | 20 | def get_body(self, emoji_manager: EmojiManager, text_manager: TextManager) -> str: 21 | try: 22 | return text_manager.session_body(session=self._session, emoji_manager=emoji_manager) 23 | except Exception as body_exception: 24 | logging.error(str(body_exception)) 25 | return f"Could not display data for session {self._session_number}" 26 | -------------------------------------------------------------------------------- /modules/discord/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/discord/services/__init__.py -------------------------------------------------------------------------------- /modules/discord/services/base_service.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | import modules.logs as logging 5 | from modules.discord import discord_utils 6 | from modules.errors import TauticordDiscordCollectionFailure 7 | from modules.utils import quote 8 | 9 | 10 | class BaseService: 11 | """ 12 | Base class for a service aspect of the Discord bot. These start running when the bot is ready (connected to Discord). 13 | """ 14 | 15 | def __init__(self): 16 | self.bot: commands.Bot = None # type: ignore 17 | 18 | async def register_bot(self, bot: commands.Bot): 19 | """ 20 | Register the bot with the service. 21 | `self.bot` will not be set until this is called. 22 | """ 23 | self.bot = bot 24 | 25 | async def associate_bot_callbacks(self): 26 | """ 27 | Associate bot callbacks with the service. 28 | `self.bot` will be set when this is called. 29 | This is where you should register your bot callbacks (`on_raw_reaction_add`, etc.). 30 | """ 31 | pass 32 | 33 | async def on_ready(self): 34 | """ 35 | Called when the bot is ready. This is where you should start your service. 36 | `self.bot` will be set when this is called. 37 | """ 38 | raise NotImplementedError 39 | 40 | async def enabled(self) -> bool: 41 | """ 42 | Return whether the service is enabled. 43 | Will run `on_ready` only if this returns True. 44 | `self.bot` will be set when this is called. 45 | """ 46 | raise NotImplementedError 47 | 48 | async def collect_discord_voice_category(self, 49 | guild_id: int, 50 | category_name: str) -> discord.CategoryChannel: 51 | assert self.bot, "Bot not registered. Exiting..." 52 | 53 | logging.debug(f"Getting {quote(category_name)} voice category") 54 | 55 | category = await discord_utils.get_or_create_discord_category_by_name(client=self.bot, 56 | guild_id=guild_id, 57 | category_name=category_name) 58 | 59 | if not category: 60 | raise TauticordDiscordCollectionFailure(f"Could not load {quote(category_name)} voice category. Exiting...") 61 | 62 | logging.debug(f"{quote(category_name)} voice category collected.") 63 | return category 64 | 65 | async def collect_discord_text_channel(self, 66 | guild_id: int, 67 | channel_name: str) -> discord.TextChannel: 68 | assert self.bot, "Bot not registered. Exiting..." 69 | 70 | logging.debug(f"Getting {quote(channel_name)} text channel") 71 | 72 | channel = await discord_utils.get_or_create_discord_channel_by_name(client=self.bot, 73 | guild_id=guild_id, 74 | channel_name=channel_name, 75 | channel_type=discord.ChannelType.text) 76 | 77 | if not channel: 78 | raise TauticordDiscordCollectionFailure(f"Could not load {quote(channel_name)} text channel. Exiting...") 79 | 80 | logging.debug(f"{quote(channel_name)} text channel collected.") 81 | return channel 82 | 83 | async def collect_discord_voice_channel(self, 84 | guild_id: int, 85 | channel_name: str) -> discord.VoiceChannel: 86 | assert self.bot, "Bot not registered. Exiting..." 87 | 88 | logging.debug(f"Getting {quote(channel_name)} voice channel") 89 | 90 | channel = await discord_utils.get_or_create_discord_channel_by_name(client=self.bot, 91 | guild_id=guild_id, 92 | channel_name=channel_name, 93 | channel_type=discord.ChannelType.voice) 94 | 95 | if not channel: 96 | raise TauticordDiscordCollectionFailure(f"Could not load {quote(channel_name)} voice channel. Exiting...") 97 | 98 | logging.debug(f"{quote(channel_name)} voice channel collected.") 99 | return channel 100 | -------------------------------------------------------------------------------- /modules/discord/services/library_stats.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import modules.logs as logging 4 | import modules.settings.models as settings_models 5 | import modules.tautulli.tautulli_connector 6 | from modules.analytics import GoogleAnalytics 7 | from modules.discord.services.base_service import BaseService 8 | from modules.emojis import EmojiManager 9 | from modules.tasks.library_stats import LibraryStats 10 | 11 | 12 | class LibraryStatsMonitor(BaseService): 13 | """ 14 | A service that monitors library statistics and sends them to Discord. Starts running when the bot is ready. 15 | """ 16 | 17 | def __init__(self, 18 | tautulli_connector: modules.tautulli.tautulli_connector.TautulliConnector, 19 | discord_settings: settings_models.Discord, 20 | stats_settings: settings_models.Stats, 21 | emoji_manager: EmojiManager, 22 | analytics: GoogleAnalytics): 23 | super().__init__() 24 | self.tautulli: modules.tautulli.tautulli_connector.TautulliConnector = tautulli_connector 25 | self.guild_id: int = discord_settings.server_id 26 | self.library_refresh_time: int = stats_settings.library.refresh_interval_seconds 27 | self.stats_settings: settings_models.Stats = stats_settings 28 | self.emoji_manager: EmojiManager = emoji_manager 29 | self.analytics: GoogleAnalytics = analytics 30 | 31 | self.library_stats_monitor: LibraryStats = None 32 | 33 | async def enabled(self) -> bool: 34 | return self.stats_settings.library.enable 35 | 36 | async def on_ready(self): 37 | logging.info("Starting Tautulli library stats service...") 38 | voice_category = await self.collect_discord_voice_category( 39 | guild_id=self.guild_id, 40 | category_name=self.stats_settings.library.category_name) 41 | # minimum 5-minute sleep time hard-coded, trust me, don't DDoS your server 42 | refresh_time = max([5 * 60, 43 | self.library_refresh_time]) 44 | self.library_stats_monitor = LibraryStats(discord_client=self.bot, 45 | settings=self.stats_settings.library, 46 | tautulli_connector=self.tautulli, 47 | guild_id=self.guild_id, 48 | voice_category=voice_category) 49 | # noinspection PyAsyncCall 50 | asyncio.create_task(self.library_stats_monitor.run_service(interval_seconds=refresh_time)) 51 | -------------------------------------------------------------------------------- /modules/discord/services/performance_stats.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import modules.logs as logging 4 | import modules.settings.models as settings_models 5 | import modules.tautulli.tautulli_connector 6 | from modules.analytics import GoogleAnalytics 7 | from modules.discord.services.base_service import BaseService 8 | from modules.emojis import EmojiManager 9 | from modules.tasks.performance_stats import PerformanceMonitor 10 | 11 | 12 | class PerformanceStatsMonitor(BaseService): 13 | """ 14 | A service that monitors performance statistics and sends them to Discord. Starts running when the bot is ready. 15 | """ 16 | 17 | def __init__(self, 18 | tautulli_connector: modules.tautulli.tautulli_connector.TautulliConnector, 19 | discord_settings: settings_models.Discord, 20 | stats_settings: settings_models.Stats, 21 | run_args_settings: settings_models.RunArgs, 22 | emoji_manager: EmojiManager, 23 | analytics: GoogleAnalytics): 24 | super().__init__() 25 | self.tautulli: modules.tautulli.tautulli_connector.TautulliConnector = tautulli_connector 26 | self.guild_id: int = discord_settings.server_id 27 | self.run_args_settings: settings_models.RunArgs = run_args_settings 28 | self.stats_settings: settings_models.Stats = stats_settings 29 | self.emoji_manager: EmojiManager = emoji_manager 30 | self.analytics: GoogleAnalytics = analytics 31 | 32 | self.performance_stats_monitor: PerformanceMonitor = None 33 | 34 | async def enabled(self) -> bool: 35 | return self.stats_settings.performance.enable 36 | 37 | async def on_ready(self): 38 | logging.info("Starting performance monitoring service...") 39 | voice_category = await self.collect_discord_voice_category( 40 | guild_id=self.guild_id, 41 | category_name=self.stats_settings.performance.category_name) 42 | # Hard-coded 5-minute refresh time 43 | refresh_time = 5 * 60 44 | self.performance_stats_monitor = PerformanceMonitor(discord_client=self.bot, 45 | settings=self.stats_settings.performance, 46 | tautulli_connector=self.tautulli, 47 | run_args_settings=self.run_args_settings, 48 | guild_id=self.guild_id, 49 | voice_category=voice_category) 50 | # noinspection PyAsyncCall 51 | asyncio.create_task(self.performance_stats_monitor.run_service(interval_seconds=refresh_time)) 52 | -------------------------------------------------------------------------------- /modules/discord/services/slash_commands.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands 3 | 4 | import modules.logs as logging 5 | from modules.discord.commands import ( 6 | Most, 7 | Summary, 8 | Recently, 9 | Graphs, 10 | ) 11 | from modules.discord.services.base_service import BaseService 12 | from modules.emojis import EmojiManager 13 | from modules.tautulli.tautulli_connector import TautulliConnector 14 | 15 | 16 | class SlashCommandManager(BaseService): 17 | """ 18 | A service that manages slash commands for the Discord bot. Starts running when the bot is ready. 19 | """ 20 | 21 | def __init__(self, enable_slash_commands: bool, guild_id: int, tautulli: TautulliConnector, 22 | emoji_manager: EmojiManager, admin_ids: list[int] = None): 23 | super().__init__() 24 | self._enable_slash_commands = enable_slash_commands 25 | self._guild_id = guild_id 26 | self._admin_ids = admin_ids or [] 27 | self._tautulli = tautulli 28 | self._emoji_manager = emoji_manager 29 | self._synced = False 30 | 31 | async def enabled(self) -> bool: 32 | return True # Always enabled, because need to sync slash commands regardless 33 | 34 | async def on_ready(self): 35 | if self._enable_slash_commands: 36 | for cog in self._cogs_to_add: 37 | await self._add_cog(cog) 38 | logging.info("Slash commands registered.") 39 | else: 40 | logging.info("Slash commands not enabled. Skipping registration...") 41 | 42 | # Need to sync regardless (either adding newly-registered cogs or syncing/removing existing ones) 43 | logging.info("Syncing slash commands...") 44 | if not self._synced: 45 | await self.bot.tree.sync(guild=self._guild) 46 | self._synced = True 47 | logging.info("Slash commands synced.") 48 | else: 49 | logging.info("Slash commands already synced.") 50 | 51 | @property 52 | def _cogs_to_add(self): 53 | return [ 54 | Most(bot=self.bot, tautulli=self._tautulli, admin_check=self.is_admin), 55 | Summary(bot=self.bot, tautulli=self._tautulli, emoji_manager=self._emoji_manager, 56 | admin_check=self.is_admin), 57 | Recently(bot=self.bot, tautulli=self._tautulli, admin_check=self.is_admin), 58 | Graphs(bot=self.bot, tautulli=self._tautulli, admin_check=self.is_admin), 59 | ] 60 | 61 | @property 62 | def _active_cogs(self): 63 | return self.bot.cogs 64 | 65 | @property 66 | def _guild(self): 67 | return discord.Object(id=self._guild_id) 68 | 69 | async def _add_cog(self, cog: commands.Cog): 70 | await self.bot.add_cog(cog, guild=self._guild) 71 | 72 | def is_admin(self, interaction: discord.Interaction) -> bool: 73 | return interaction.user.id in self._admin_ids 74 | -------------------------------------------------------------------------------- /modules/discord/services/tagged_message.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | import discord 4 | 5 | import modules.statics as statics 6 | from modules.discord.services.base_service import BaseService 7 | from modules.emojis import EmojiManager 8 | 9 | 10 | def build_response(message: discord.Message, bot_id: int, admin_ids: List[int]) -> Union[str, None]: 11 | # If message does not mention the bot, return None 12 | if bot_id not in [user.id for user in message.mentions]: 13 | return None 14 | 15 | author_id = str(message.author.id) 16 | if author_id == "DISCORDIDADDEDBYGITHUB": 17 | return "Hello, creator!" 18 | 19 | if author_id not in [str(admin_id) for admin_id in admin_ids]: 20 | return None 21 | 22 | # Message mentions the bot and is from an admin 23 | return statics.INFO_SUMMARY 24 | 25 | 26 | class TaggedMessagesManager(BaseService): 27 | """ 28 | A service that manages how to bot interacts with tagged messages. Starts running when the bot is ready. 29 | """ 30 | 31 | def __init__(self, guild_id: int, 32 | emoji_manager: EmojiManager, 33 | admin_ids: list[int] = None): 34 | super().__init__() 35 | self.guild_id = guild_id 36 | self.admin_ids = admin_ids or [] 37 | self.emoji_manager = emoji_manager 38 | 39 | async def associate_bot_callbacks(self): 40 | # noinspection PyAttributeOutsideInit 41 | self.on_message = self.bot.event(self.on_message) 42 | 43 | async def enabled(self) -> bool: 44 | return True 45 | 46 | async def on_ready(self): 47 | pass 48 | 49 | async def on_message(self, message: discord.Message) -> None: 50 | response = build_response(message=message, bot_id=self.bot.user.id, admin_ids=self.admin_ids) 51 | 52 | if response: 53 | await message.channel.send(response) 54 | -------------------------------------------------------------------------------- /modules/discord/views/__init__.py: -------------------------------------------------------------------------------- 1 | from modules.discord.views.paginated_view import ( 2 | ButtonColor, 3 | EmbedColor, 4 | PaginatedViewStyle, 5 | PaginatedListView, 6 | PaginatedListViewItem, 7 | PaginatedCardView, 8 | PaginatedCardViewItem, 9 | ) 10 | -------------------------------------------------------------------------------- /modules/errors.py: -------------------------------------------------------------------------------- 1 | import discord 2 | 3 | 4 | class TauticordException(Exception): 5 | """ 6 | Base exception class for Tauticord 7 | """ 8 | code: int 9 | 10 | def __init__(self, code: int, message: str): 11 | self.code: int = code or 300 # Default to 300 if no code is provided 12 | super().__init__(message) 13 | 14 | 15 | class TauticordMigrationFailure(TauticordException): 16 | """ 17 | Raised when an error occurs during Tauticord migrations 18 | """ 19 | 20 | def __init__(self, message: str): 21 | super().__init__(code=301, message=message) 22 | 23 | 24 | class TauticordSetupFailure(TauticordException): 25 | """ 26 | Raised when an error occurs during Tauticord setup 27 | """ 28 | 29 | def __init__(self, message: str): 30 | super().__init__(code=302, message=message) 31 | 32 | 33 | class TauticordDiscordCollectionFailure(TauticordException): 34 | """ 35 | Raised when an error occurs during collecting a Discord object 36 | """ 37 | 38 | def __init__(self, message: str): 39 | super().__init__(code=303, message=message) 40 | 41 | 42 | class TauticordAPIFailure(TauticordException): 43 | """ 44 | Raised when an error occurs during an API call 45 | """ 46 | 47 | def __init__(self, message: str): 48 | super().__init__(code=304, message=message) 49 | 50 | 51 | def determine_exit_code(exception: Exception) -> int: 52 | """ 53 | Determine the exit code based on the exception that was thrown 54 | 55 | :param exception: The exception that was thrown 56 | :return: The exit code 57 | """ 58 | if isinstance(exception, discord.LoginFailure): 59 | return 101 # Invalid Discord token 60 | elif isinstance(exception, discord.PrivilegedIntentsRequired): 61 | return 102 # Privileged intents are required 62 | elif isinstance(exception, TauticordException): 63 | return exception.code 64 | else: 65 | return 1 66 | -------------------------------------------------------------------------------- /modules/logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from datetime import datetime 4 | from logging.handlers import RotatingFileHandler 5 | from typing import Optional 6 | 7 | from pytz import timezone 8 | 9 | _nameToLevel = { 10 | 'CRITICAL': logging.CRITICAL, 11 | 'FATAL': logging.FATAL, 12 | 'ERROR': logging.ERROR, 13 | 'WARN': logging.WARNING, 14 | 'WARNING': logging.WARNING, 15 | 'INFO': logging.INFO, 16 | 'DEBUG': logging.DEBUG, 17 | 'NOTSET': logging.NOTSET, 18 | } 19 | 20 | _DEFAULT_LOGGER_NAME = None 21 | MAX_SIZE = 5000000 # 5 MB 22 | MAX_FILES = 5 23 | 24 | 25 | def init(app_name: str, 26 | console_log_level: str, 27 | log_to_file: Optional[bool] = False, 28 | log_file_dir: Optional[str] = "", 29 | file_log_level: Optional[str] = None): 30 | global _DEFAULT_LOGGER_NAME 31 | _DEFAULT_LOGGER_NAME = app_name 32 | 33 | logger = logging.getLogger(app_name) 34 | 35 | # Default log to DEBUG 36 | logger.setLevel(logging.DEBUG) 37 | 38 | formatter = logging.Formatter('%(asctime)s - [%(levelname)s]: %(message)s') 39 | timezone_abbr = os.getenv('TZ', 'UTC') 40 | formatter.converter = lambda *args: datetime.now(tz=timezone(timezone_abbr)).timetuple() 41 | 42 | # Console logging 43 | console_logger = logging.StreamHandler() 44 | console_logger.setFormatter(formatter) 45 | console_logger.setLevel(level_name_to_level(console_log_level)) 46 | logger.addHandler(console_logger) 47 | 48 | # File logging 49 | if log_to_file: 50 | log_file_dir = log_file_dir if log_file_dir.endswith('/') else f'{log_file_dir}/' 51 | try: 52 | os.makedirs(log_file_dir) 53 | except: 54 | pass 55 | file_logger = RotatingFileHandler(filename=f'{log_file_dir}{app_name}.log', 56 | maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding='utf-8') 57 | file_logger.setFormatter(formatter) 58 | file_logger.setLevel(level_name_to_level(file_log_level or console_log_level)) 59 | logger.addHandler(file_logger) 60 | 61 | 62 | def level_name_to_level(level_name: str): 63 | return _nameToLevel.get(level_name, _nameToLevel['NOTSET']) 64 | 65 | 66 | def warning(message: str, specific_logger: Optional[str] = None): 67 | logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).warning(msg=message) 68 | 69 | 70 | def info(message: str, specific_logger: Optional[str] = None): 71 | logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).info(msg=message) 72 | 73 | 74 | def debug(message: str, specific_logger: Optional[str] = None): 75 | logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).debug(msg=message) 76 | 77 | 78 | def error(message: str, specific_logger: Optional[str] = None): 79 | logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).error(msg=message) 80 | 81 | 82 | def critical(message: str, specific_logger: Optional[str] = None): 83 | logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).critical(msg=message) 84 | 85 | 86 | def fatal(message: str, specific_logger: Optional[str] = None): 87 | logging.getLogger(specific_logger if specific_logger else _DEFAULT_LOGGER_NAME).critical(msg=message) 88 | -------------------------------------------------------------------------------- /modules/models/__init__.py: -------------------------------------------------------------------------------- 1 | from modules.models.recently_added_item import RecentlyAddedItem 2 | from modules.models.webhook import Webhook 3 | -------------------------------------------------------------------------------- /modules/models/recently_added_item.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from modules.database.models.recently_added_item import RecentlyAddedItem as RecentlyAddedItemDatabaseModel 4 | 5 | 6 | class RecentlyAddedItem(BaseModel): 7 | name: str 8 | library_name: str 9 | added_at: int 10 | 11 | @classmethod 12 | def from_database_record(cls, record: RecentlyAddedItemDatabaseModel) -> 'RecentlyAddedItem': 13 | return cls( 14 | name=record.name, 15 | library_name=record.library_name, 16 | added_at=record.created_at 17 | ) 18 | -------------------------------------------------------------------------------- /modules/models/webhook.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from tautulli.tools.webhooks import TautulliWebhookTrigger 3 | 4 | from modules.database.models.webhooks import Webhook as WebhookDatabaseModel 5 | 6 | 7 | class Webhook(BaseModel): 8 | webhook_type: TautulliWebhookTrigger 9 | received_at: int 10 | 11 | @classmethod 12 | def from_database_record(cls, record: WebhookDatabaseModel) -> 'Webhook': 13 | return cls( 14 | webhook_type=record.webhook_type, 15 | received_at=record.created_at 16 | ) 17 | -------------------------------------------------------------------------------- /modules/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/settings/__init__.py -------------------------------------------------------------------------------- /modules/settings/models/__init__.py: -------------------------------------------------------------------------------- 1 | from modules.settings.models.anonymity import Anonymity 2 | from modules.settings.models.discord import Discord, StatusMessage as DiscordStatusMessage 3 | from modules.settings.models.display import Display 4 | from modules.settings.models.extras import Extras 5 | from modules.settings.models.libraries import BaseLibrary, Library, CombinedLibrary, CombinedLibrarySubLibrary 6 | from modules.settings.models.run_args import RunArgs 7 | from modules.settings.models.stats import Stats, LibraryStats, ActivityStats, PerformanceStats 8 | from modules.settings.models.tautulli import Tautulli 9 | from modules.settings.models.time import Time 10 | from modules.settings.models.voice_category import VoiceCategory 11 | from modules.settings.models.voice_channel import VoiceChannel, LibraryVoiceChannels, RecentlyAddedVoiceChannel 12 | -------------------------------------------------------------------------------- /modules/settings/models/anonymity.py: -------------------------------------------------------------------------------- 1 | from modules.settings.models.base import BaseConfig 2 | 3 | 4 | class Anonymity(BaseConfig): 5 | hide_bandwidth: bool 6 | hide_eta: bool 7 | hide_platforms: bool 8 | hide_player_names: bool 9 | hide_progress: bool 10 | hide_quality: bool 11 | hide_transcode_decision: bool 12 | hide_usernames: bool 13 | 14 | def as_dict(self) -> dict: 15 | return { 16 | "hide_bandwidth": self.hide_bandwidth, 17 | "hide_eta": self.hide_eta, 18 | "hide_platforms": self.hide_platforms, 19 | "hide_player_names": self.hide_player_names, 20 | "hide_progress": self.hide_progress, 21 | "hide_quality": self.hide_quality 22 | } 23 | -------------------------------------------------------------------------------- /modules/settings/models/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class BaseConfig(BaseModel): 5 | def as_dict(self) -> dict: 6 | raise NotImplementedError 7 | -------------------------------------------------------------------------------- /modules/settings/models/discord.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from modules.settings.models.base import BaseConfig 4 | from modules.utils import mark_exists 5 | 6 | 7 | class StatusMessage(BaseConfig): 8 | enable: bool 9 | activity_name: Optional[str] 10 | custom_message: Optional[str] 11 | show_stream_count: bool 12 | 13 | @property 14 | def should_update_on_startup(self) -> bool: 15 | return self.enable 16 | 17 | @property 18 | def should_update_with_activity(self) -> bool: 19 | return self.enable 20 | 21 | def message(self, stream_count: int, fallback: Optional[str] = None) -> str: 22 | if self.custom_message: 23 | return self.custom_message 24 | 25 | if fallback: 26 | return fallback 27 | 28 | if self.show_stream_count: 29 | return f"Currently {stream_count} stream{'s' if stream_count != 1 else ''} active" 30 | else: 31 | return "https://github.com/nwithan8/tauticord" 32 | 33 | def as_dict(self) -> dict: 34 | return { 35 | "enable": self.enable, 36 | "activity_name": self.activity_name, 37 | "custom_message": self.custom_message, 38 | "show_stream_count": self.show_stream_count 39 | } 40 | 41 | 42 | class Discord(BaseConfig): 43 | admin_ids: list[int] 44 | bot_token: str 45 | channel_name: str 46 | enable_slash_commands: bool 47 | use_summary_message: bool 48 | enable_termination: bool 49 | server_id: int 50 | status_message_settings: StatusMessage 51 | 52 | def as_dict(self) -> dict: 53 | return { 54 | "admin_ids": self.admin_ids, 55 | "bot_token": mark_exists(self.bot_token), 56 | "channel_name": self.channel_name, 57 | "enable_slash_commands": self.enable_slash_commands, 58 | "use_summary_message": self.use_summary_message, 59 | "enable_termination": self.enable_termination, 60 | "server_id": self.server_id, 61 | "status_message_settings": self.status_message_settings.as_dict() 62 | } 63 | -------------------------------------------------------------------------------- /modules/settings/models/display.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from modules.settings.models.anonymity import Anonymity 4 | from modules.settings.models.base import BaseConfig 5 | from modules.settings.models.time import Time 6 | from modules.text_manager import TextManager 7 | 8 | 9 | class Display(BaseConfig): 10 | anonymity: Anonymity 11 | plex_server_name: str 12 | thousands_separator: Optional[str] = "" 13 | time: Time 14 | use_friendly_names: bool 15 | 16 | @property 17 | def text_manager(self) -> TextManager: 18 | return TextManager( 19 | hide_usernames=self.anonymity.hide_usernames, 20 | hide_player_names=self.anonymity.hide_player_names, 21 | hide_platforms=self.anonymity.hide_platforms, 22 | hide_quality=self.anonymity.hide_quality, 23 | hide_bandwidth=self.anonymity.hide_bandwidth, 24 | hide_transcoding=self.anonymity.hide_transcode_decision, 25 | hide_progress=self.anonymity.hide_progress, 26 | hide_eta=self.anonymity.hide_eta, 27 | use_friendly_names=self.use_friendly_names, 28 | time_manager=self.time.time_manager 29 | ) 30 | 31 | def as_dict(self) -> dict: 32 | return { 33 | "anonymity": self.anonymity.as_dict(), 34 | "plex_server_name": self.plex_server_name, 35 | "thousands_separator": self.thousands_separator, 36 | "time": self.time.as_dict(), 37 | "use_friendly_names": self.use_friendly_names 38 | } 39 | -------------------------------------------------------------------------------- /modules/settings/models/extras.py: -------------------------------------------------------------------------------- 1 | from modules.settings.models.base import BaseConfig 2 | 3 | 4 | class Extras(BaseConfig): 5 | allow_analytics: bool 6 | update_reminders: bool 7 | 8 | def as_dict(self) -> dict: 9 | return { 10 | "allow_analytics": self.allow_analytics, 11 | "update_reminders": self.update_reminders, 12 | } 13 | -------------------------------------------------------------------------------- /modules/settings/models/libraries.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from modules.settings.models.base import BaseConfig 4 | from modules.settings.models.voice_channel import LibraryVoiceChannels 5 | 6 | 7 | class BaseLibrary(BaseConfig): 8 | name: str 9 | voice_channels: LibraryVoiceChannels 10 | 11 | def as_dict(self) -> dict: 12 | return { 13 | "name": self.name, 14 | "voice_channels": self.voice_channels.as_dict() 15 | } 16 | 17 | 18 | class Library(BaseLibrary): 19 | alternate_name: str 20 | id: Optional[int] 21 | 22 | @property 23 | def library_id(self) -> Union[int, None]: 24 | if not self.id: 25 | return None 26 | 27 | return self.id if self.id > 0 else None 28 | 29 | def as_dict(self) -> dict: 30 | return { 31 | "name": self.name, 32 | "id": self.id, 33 | "alternate_name": self.alternate_name, 34 | "voice_channels": self.voice_channels.as_dict() 35 | } 36 | 37 | 38 | class CombinedLibrarySubLibrary(BaseConfig): 39 | name: str 40 | id: int 41 | 42 | @property 43 | def library_id(self) -> Union[int, None]: 44 | if not self.id: 45 | return None 46 | return self.id if self.id > 0 else None 47 | 48 | def as_dict(self) -> dict: 49 | return { 50 | "name": self.name, 51 | "id": self.id 52 | } 53 | 54 | 55 | class CombinedLibrary(BaseLibrary): 56 | libraries: list[CombinedLibrarySubLibrary] 57 | 58 | def as_dict(self) -> dict: 59 | return { 60 | "name": self.name, 61 | "libraries": [library.as_dict() for library in self.libraries], 62 | "voice_channels": self.voice_channels.as_dict() 63 | } 64 | -------------------------------------------------------------------------------- /modules/settings/models/run_args.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from modules.settings.models.base import BaseConfig 4 | 5 | 6 | class RunArgs(BaseConfig): 7 | performance_disk_space_mapping: Optional[str] = None 8 | log_path: Optional[str] = None 9 | config_path: Optional[str] = None 10 | database_path: Optional[str] = None 11 | 12 | def as_dict(self) -> dict: 13 | return { 14 | "log_path": self.log_path, 15 | "config_path": self.config_path, 16 | "database_path": self.database_path, 17 | "performance_disk_space_mapping": self.performance_disk_space_mapping, 18 | } 19 | -------------------------------------------------------------------------------- /modules/settings/models/stats.py: -------------------------------------------------------------------------------- 1 | from modules.settings.models.base import BaseConfig 2 | 3 | from modules.settings.models.voice_category import ActivityStats, LibraryStats, PerformanceStats 4 | 5 | 6 | class Stats(BaseConfig): 7 | activity: ActivityStats 8 | library: LibraryStats 9 | performance: PerformanceStats 10 | 11 | def as_dict(self) -> dict: 12 | return { 13 | "activity": self.activity.as_dict(), 14 | "library": self.library.as_dict(), 15 | "performance": self.performance.as_dict() 16 | } 17 | -------------------------------------------------------------------------------- /modules/settings/models/tautulli.py: -------------------------------------------------------------------------------- 1 | from modules.settings.models.base import BaseConfig 2 | from modules.utils import mark_exists 3 | 4 | 5 | class Tautulli(BaseConfig): 6 | url: str 7 | api_key: str 8 | ignore_ssl: bool 9 | refresh_interval_seconds: int 10 | termination_message: str 11 | 12 | def as_dict(self) -> dict: 13 | return { 14 | "url": self.url, 15 | "api_key": mark_exists(self.api_key), 16 | "ignore_ssl": self.ignore_ssl, 17 | "refresh_interval_seconds": self.refresh_interval_seconds, 18 | "termination_message": self.termination_message 19 | } 20 | -------------------------------------------------------------------------------- /modules/settings/models/time.py: -------------------------------------------------------------------------------- 1 | from modules.settings.models.base import BaseConfig 2 | 3 | from modules.time_manager import TimeManager 4 | 5 | 6 | class Time(BaseConfig): 7 | @property 8 | def time_manager(self): 9 | return TimeManager() 10 | 11 | def as_dict(self) -> dict: 12 | return { 13 | } 14 | -------------------------------------------------------------------------------- /modules/settings/models/voice_category.py: -------------------------------------------------------------------------------- 1 | from modules.settings.models.base import BaseConfig 2 | 3 | from modules.settings.models.libraries import CombinedLibrary, Library 4 | from modules.settings.models.voice_channel import VoiceChannel 5 | 6 | 7 | class VoiceCategory(BaseConfig): 8 | category_name: str 9 | enable: bool 10 | 11 | def as_dict(self) -> dict: 12 | raise NotImplementedError 13 | 14 | def channel_order(self) -> dict: 15 | raise NotImplementedError 16 | 17 | 18 | class ActivityStats(VoiceCategory): 19 | bandwidth: VoiceChannel 20 | local_bandwidth: VoiceChannel 21 | remote_bandwidth: VoiceChannel 22 | stream_count: VoiceChannel 23 | transcode_count: VoiceChannel 24 | plex_availability: VoiceChannel 25 | 26 | def as_dict(self) -> dict: 27 | return { 28 | "category_name": self.category_name, 29 | "enable": self.enable, 30 | "bandwidth": self.bandwidth.as_dict(), 31 | "local_bandwidth": self.local_bandwidth.as_dict(), 32 | "remote_bandwidth": self.remote_bandwidth.as_dict(), 33 | "stream_count": self.stream_count.as_dict(), 34 | "transcode_count": self.transcode_count.as_dict(), 35 | "plex_availability": self.plex_availability.as_dict() 36 | } 37 | 38 | 39 | class LibraryStats(VoiceCategory): 40 | libraries: list[Library] 41 | combined_libraries: list[CombinedLibrary] 42 | refresh_interval_seconds: int 43 | 44 | def as_dict(self) -> dict: 45 | return { 46 | "category_name": self.category_name, 47 | "enable": self.enable, 48 | "libraries": [library.as_dict() for library in self.libraries], 49 | "combined_libraries": [library.as_dict() for library in self.combined_libraries], 50 | "refresh_interval_seconds": self.refresh_interval_seconds 51 | } 52 | 53 | 54 | class PerformanceStats(VoiceCategory): 55 | cpu: VoiceChannel 56 | memory: VoiceChannel 57 | disk: VoiceChannel 58 | user_count: VoiceChannel 59 | 60 | def as_dict(self) -> dict: 61 | return { 62 | "category_name": self.category_name, 63 | "enable": self.enable, 64 | "cpu": self.cpu.as_dict(), 65 | "memory": self.memory.as_dict(), 66 | "disk": self.disk.as_dict(), 67 | "user_count": self.user_count.as_dict() 68 | } 69 | -------------------------------------------------------------------------------- /modules/settings/models/voice_channel.py: -------------------------------------------------------------------------------- 1 | from modules.settings.models.base import BaseConfig 2 | from modules.utils import strip_phantom_space 3 | 4 | 5 | class VoiceChannel(BaseConfig): 6 | name: str 7 | enable: bool 8 | emoji: str 9 | channel_id: int = 0 10 | 11 | @property 12 | def prefix(self) -> str: 13 | emoji = strip_phantom_space(string=self.emoji) 14 | prefix = f"{emoji} {self.name}" 15 | return prefix.strip() # Remove any spaces provided to override default name/emoji 16 | 17 | @property 18 | def channel_id_set(self) -> bool: 19 | return self.channel_id != 0 20 | 21 | def build_channel_name(self, value) -> str: 22 | prefix = self.prefix 23 | if not prefix: 24 | return value 25 | return f"{self.prefix}: {value}" 26 | 27 | def as_dict(self) -> dict: 28 | return { 29 | "name": self.name, 30 | "enable": self.enable, 31 | "emoji": self.emoji, 32 | "channel_id": self.channel_id 33 | } 34 | 35 | 36 | class RecentlyAddedVoiceChannel(VoiceChannel): 37 | hours: int = 24 38 | 39 | def as_dict(self) -> dict: 40 | return { 41 | "name": self.name, 42 | "enable": self.enable, 43 | "emoji": self.emoji, 44 | "channel_id": self.channel_id, 45 | "hours": self.hours 46 | } 47 | 48 | 49 | class LibraryVoiceChannels(BaseConfig): 50 | movie: VoiceChannel 51 | album: VoiceChannel 52 | artist: VoiceChannel 53 | episode: VoiceChannel 54 | series: VoiceChannel 55 | track: VoiceChannel 56 | recently_added: RecentlyAddedVoiceChannel 57 | 58 | @property 59 | def _channels(self) -> list[VoiceChannel]: 60 | return [self.movie, self.album, self.artist, self.episode, self.series, self.track, self.recently_added] 61 | 62 | @property 63 | def enabled_channels(self) -> list[VoiceChannel]: 64 | return [channel for channel in self._channels if channel.enable] 65 | 66 | def as_dict(self) -> dict: 67 | return { 68 | "movie": self.movie.as_dict(), 69 | "album": self.album.as_dict(), 70 | "artist": self.artist.as_dict(), 71 | "episode": self.episode.as_dict(), 72 | "series": self.series.as_dict(), 73 | "track": self.track.as_dict(), 74 | "recently_added": self.recently_added.as_dict() 75 | } 76 | -------------------------------------------------------------------------------- /modules/statics.py: -------------------------------------------------------------------------------- 1 | # Number 1-9, and A-Z 2 | import subprocess 3 | import sys 4 | 5 | VERSION = "VERSIONADDEDBYGITHUB" 6 | COPYRIGHT = "Copyright © YEARADDEDBYGITHUB Nate Harris. All rights reserved." 7 | UNKNOWN_COMMIT_HASH = "unknown-commit" 8 | 9 | CUSTOM_EMOJIS_FOLDER = "resources/emojis" 10 | CHART_FOLDER = "generated/charts" 11 | CHART_IMAGE_PATH = f"{CHART_FOLDER}/chart.png" 12 | 13 | MONITORED_DISK_SPACE_FOLDER = "/monitor" 14 | 15 | BOT_PREFIX = "tc-" 16 | EMOJI_PREFIX = "tc" 17 | 18 | MAX_EMBED_FIELD_NAME_LENGTH = 200 # 256 - estimated emojis + flairs + safety margin 19 | 20 | KEY_RUN_ARGS_MONITOR_PATH = "run_args_monitor_path" 21 | KEY_RUN_ARGS_CONFIG_PATH = "run_args_config_path" 22 | KEY_RUN_ARGS_LOG_PATH = "run_args_log_path" 23 | KEY_RUN_ARGS_DATABASE_PATH = "run_args_database_path" 24 | 25 | MAX_STREAM_COUNT = 20 # max number of emojis one user can post on a single message 26 | 27 | ASCII_ART = """___________________ ______________________________________________ 28 | ___ __/__ |_ / / /__ __/___ _/_ ____/_ __ \__ __ \__ __ \\ 29 | __ / __ /| | / / /__ / __ / _ / _ / / /_ /_/ /_ / / / 30 | _ / _ ___ / /_/ / _ / __/ / / /___ / /_/ /_ _, _/_ /_/ / 31 | /_/ /_/ |_\____/ /_/ /___/ \____/ \____/ /_/ |_| /_____/ 32 | """ 33 | 34 | INFO_SUMMARY = f"""Version: {VERSION} 35 | """ 36 | 37 | 38 | def get_sha_hash(sha: str) -> str: 39 | return f"git-{sha[0:7]}" 40 | 41 | 42 | def get_last_commit_hash() -> str: 43 | """ 44 | Get the seven character commit hash of the last commit. 45 | """ 46 | try: 47 | sha = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf-8").strip() 48 | except subprocess.SubprocessError: 49 | sha = UNKNOWN_COMMIT_HASH 50 | 51 | return get_sha_hash(sha) 52 | 53 | 54 | def is_git() -> bool: 55 | return "GITHUB" in VERSION 56 | 57 | 58 | def get_version() -> str: 59 | if not is_git(): 60 | return VERSION 61 | 62 | return get_last_commit_hash() 63 | 64 | 65 | def splash_logo() -> str: 66 | version = get_version() 67 | return f""" 68 | {ASCII_ART} 69 | Version {version}, Python {sys.version} 70 | 71 | {COPYRIGHT} 72 | """ 73 | -------------------------------------------------------------------------------- /modules/system_stats.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from modules import utils 5 | 6 | 7 | def path_exists(path: str) -> bool: 8 | """ 9 | Check if a path exists 10 | 11 | :param path: Path to check 12 | :type path: str 13 | :return: True if path exists, False if not 14 | :rtype: bool 15 | """ 16 | return os.path.exists(path) 17 | 18 | 19 | def disk_space_info(path: str) -> tuple: 20 | """ 21 | Get the current disk usage total, used, and free in bytes 22 | 23 | :param path: Path to get disk usage for 24 | :type path: str 25 | :return: Disk usage total, used, and free in bytes 26 | :rtype: tuple 27 | """ 28 | total, used, free = shutil.disk_usage(path) 29 | return total, used, free 30 | 31 | 32 | def disk_usage_display(path: str) -> str: 33 | """ 34 | Get the current disk usage display 35 | 36 | :param path: Path to get disk usage for 37 | :type path: str 38 | :return: Disk usage display 39 | :rtype: str 40 | """ 41 | total, used, free = disk_space_info(path) 42 | 43 | space_used = utils.human_size(used, decimal_places=1, no_zeros=True) 44 | total_space = utils.human_size(total, decimal_places=1, no_zeros=True) 45 | 46 | return f"{space_used}/{total_space}" 47 | -------------------------------------------------------------------------------- /modules/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/tasks/__init__.py -------------------------------------------------------------------------------- /modules/tasks/library_stats.py: -------------------------------------------------------------------------------- 1 | import discord 2 | 3 | import modules.logs as logging 4 | import modules.settings.models 5 | from modules.tasks.voice_category_stats import VoiceCategoryStatsMonitor 6 | from modules.tautulli.models.library_item_counts import LibraryItemCounts 7 | from modules.tautulli.tautulli_connector import TautulliConnector 8 | 9 | 10 | class LibraryStats(VoiceCategoryStatsMonitor): 11 | """ 12 | A cron-based service loop that updates the library stats voice channels. 13 | """ 14 | 15 | def __init__(self, 16 | discord_client, 17 | settings: modules.settings.models.LibraryStats, 18 | tautulli_connector: TautulliConnector, 19 | guild_id: int, 20 | voice_category: discord.CategoryChannel = None): 21 | super().__init__(discord_client=discord_client, 22 | guild_id=guild_id, 23 | service_entrypoint=self.update_library_stats, 24 | voice_category=voice_category) 25 | self.stats_settings = settings 26 | self.tautulli = tautulli_connector 27 | 28 | async def update_library_stats_for_library(self, 29 | library_settings: modules.settings.models.BaseLibrary, 30 | item_counts: LibraryItemCounts) -> None: 31 | """ 32 | Update the individual stat voice channels for a single library/combined library 33 | (e.g. "My Library" - Movies, Shows, Episodes, Artists, Albums, Tracks) 34 | """ 35 | if not item_counts: 36 | return 37 | 38 | match item_counts.library_type: 39 | case modules.tautulli.tautulli_connector.LibraryType.MOVIE: 40 | if library_settings.voice_channels.movie.enable: 41 | await self.edit_stat_voice_channel(voice_channel_settings=library_settings.voice_channels.movie, 42 | stat=item_counts.movies) 43 | case modules.tautulli.tautulli_connector.LibraryType.SHOW: 44 | if library_settings.voice_channels.series.enable: 45 | await self.edit_stat_voice_channel(voice_channel_settings=library_settings.voice_channels.series, 46 | stat=item_counts.series) 47 | if library_settings.voice_channels.episode.enable: 48 | await self.edit_stat_voice_channel(voice_channel_settings=library_settings.voice_channels.episode, 49 | stat=item_counts.episodes) 50 | case modules.tautulli.tautulli_connector.LibraryType.MUSIC: 51 | if library_settings.voice_channels.artist.enable: 52 | await self.edit_stat_voice_channel(voice_channel_settings=library_settings.voice_channels.artist, 53 | stat=item_counts.artists) 54 | if library_settings.voice_channels.album.enable: 55 | await self.edit_stat_voice_channel(voice_channel_settings=library_settings.voice_channels.album, 56 | stat=item_counts.albums) 57 | if library_settings.voice_channels.track.enable: 58 | await self.edit_stat_voice_channel(voice_channel_settings=library_settings.voice_channels.track, 59 | stat=item_counts.tracks) 60 | 61 | async def update_library_stats(self) -> None: 62 | """ 63 | Update the individual stat voice channels for each regular library and each combined library 64 | """ 65 | logging.info("Updating library stats...") 66 | 67 | # Only got here because library stats are enabled, no need to check 68 | 69 | # Regular libraries 70 | for library_settings in self.stats_settings.libraries: 71 | item_counts: modules.tautulli.tautulli_connector.LibraryItemCounts = ( 72 | self.tautulli.get_item_counts_for_a_single_library( 73 | library_name=library_settings.name, 74 | library_id=library_settings.library_id)) 75 | 76 | await self.update_library_stats_for_library(library_settings=library_settings, item_counts=item_counts) 77 | 78 | if library_settings.voice_channels.recently_added.enable: 79 | minutes = library_settings.voice_channels.recently_added.hours * 60 80 | recently_added_count: int = ( 81 | self.tautulli.get_recently_added_count_for_library( 82 | library_name=library_settings.name, 83 | minutes=minutes)) 84 | 85 | await self.edit_stat_voice_channel( 86 | voice_channel_settings=library_settings.voice_channels.recently_added, 87 | stat=recently_added_count) 88 | 89 | # Combined libraries 90 | for library_settings in self.stats_settings.combined_libraries: 91 | item_counts: modules.tautulli.tautulli_connector.LibraryItemCounts = ( 92 | self.tautulli.get_item_counts_for_multiple_combined_libraries( 93 | combined_library_name=library_settings.name, 94 | sub_libraries=library_settings.libraries)) 95 | 96 | await self.update_library_stats_for_library(library_settings=library_settings, item_counts=item_counts) 97 | 98 | if library_settings.voice_channels.recently_added.enable: 99 | minutes = library_settings.voice_channels.recently_added.hours * 60 100 | recently_added_count: int = ( 101 | self.tautulli.get_recently_added_count_for_combined_libraries( 102 | sub_libraries=library_settings.libraries, 103 | minutes=minutes)) 104 | 105 | await self.edit_stat_voice_channel( 106 | voice_channel_settings=library_settings.voice_channels.recently_added, 107 | stat=recently_added_count) 108 | -------------------------------------------------------------------------------- /modules/tasks/performance_stats.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import discord 4 | import plexapi.server 5 | 6 | import modules.logs as logging 7 | import modules.settings.models 8 | from modules import system_stats 9 | from modules.tasks.voice_category_stats import VoiceCategoryStatsMonitor 10 | from modules.tautulli.tautulli_connector import TautulliConnector 11 | from modules.utils import quote 12 | 13 | 14 | class PerformanceMonitor(VoiceCategoryStatsMonitor): 15 | """ 16 | A cron-based service loop that updates the performance stats voice channels. 17 | """ 18 | 19 | def __init__(self, 20 | discord_client, 21 | settings: modules.settings.models.PerformanceStats, 22 | run_args_settings: modules.settings.models.RunArgs, 23 | tautulli_connector: TautulliConnector, 24 | guild_id: int, 25 | voice_category: discord.CategoryChannel = None): 26 | super().__init__(discord_client=discord_client, 27 | guild_id=guild_id, 28 | service_entrypoint=self.update_performance_stats, 29 | voice_category=voice_category) 30 | self.stats_settings = settings 31 | self.run_args_settings = run_args_settings 32 | self.tautulli = tautulli_connector 33 | 34 | def calculate_cpu_percent(self) -> str: 35 | if not self.tautulli.plex_api: 36 | logging.error("No Plex API found to monitor CPU usage.") 37 | return "N/A" 38 | 39 | resources: List[plexapi.server.StatisticsResources] = self.tautulli.plex_api.resources() 40 | 41 | if not resources: 42 | logging.error("Could not load CPU usage from Plex API.") 43 | return "N/A" 44 | 45 | # Get the last resource (most recent) 46 | resource = resources[-1] 47 | # Process - Plex Media Server, Host - Host 48 | return f"{resource.processCpuUtilization:.2f}%" # 0.00% 49 | 50 | def calculate_memory_percent(self) -> str: 51 | if not self.tautulli.plex_api: 52 | logging.error("No Plex API found to monitor RAM usage.") 53 | return "N/A" 54 | 55 | resources: List[plexapi.server.StatisticsResources] = self.tautulli.plex_api.resources() 56 | 57 | if not resources: 58 | logging.error("Could not load RAM usage from Plex API.") 59 | return "N/A" 60 | 61 | # Get the last resource (most recent) 62 | resource = resources[-1] 63 | # Process - Plex Media Server, Host - Host 64 | return f"{resource.processMemoryUtilization:.2f}%" # 0.00% 65 | 66 | def calculate_disk_usage(self) -> str: 67 | path = self.run_args_settings.performance_disk_space_mapping 68 | if not system_stats.path_exists(path): 69 | logging.error(f"Could not find {quote(path)} to monitor disk space.") 70 | return "N/A" 71 | else: 72 | return system_stats.disk_usage_display(path) 73 | 74 | async def update_performance_stats(self) -> None: 75 | logging.info("Updating performance stats...") 76 | 77 | # Only got here because performance stats are enabled, no need to check 78 | 79 | if self.stats_settings.user_count.enable: 80 | settings = self.stats_settings.user_count 81 | user_count = self.tautulli.get_user_count() 82 | logging.debug(f"Updating Users voice channel with new user count: {user_count}") 83 | await self.edit_stat_voice_channel(voice_channel_settings=settings, 84 | stat=user_count) 85 | 86 | if self.stats_settings.disk.enable: 87 | settings = self.stats_settings.disk 88 | stat = self.calculate_disk_usage() 89 | logging.debug(f"Updating Disk voice channel with new disk space: {stat}") 90 | await self.edit_stat_voice_channel(voice_channel_settings=settings, 91 | stat=stat) 92 | 93 | if self.tautulli.plex_pass_feature_is_allowed(feature=self.stats_settings.cpu.enable, 94 | warning="CPU usage stats require Plex Pass, ignoring setting..."): 95 | settings = self.stats_settings.cpu 96 | cpu_percent = self.calculate_cpu_percent() 97 | logging.debug(f"Updating CPU voice channel with new CPU percent: {cpu_percent}") 98 | await self.edit_stat_voice_channel(voice_channel_settings=settings, 99 | stat=cpu_percent) 100 | 101 | if self.tautulli.plex_pass_feature_is_allowed(feature=self.stats_settings.memory.enable, 102 | warning="Memory usage stats require Plex Pass, ignoring setting..."): 103 | settings = self.stats_settings.memory 104 | memory_percent = self.calculate_memory_percent() 105 | logging.debug(f"Updating Memory voice channel with new Memory percent: {memory_percent}") 106 | await self.edit_stat_voice_channel(voice_channel_settings=settings, 107 | stat=memory_percent) 108 | -------------------------------------------------------------------------------- /modules/tasks/voice_category_stats.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Union, Callable 3 | 4 | import discord 5 | 6 | import modules.logs as logging 7 | from modules.discord import discord_utils 8 | from modules.settings.models import VoiceChannel 9 | 10 | 11 | class VoiceCategoryStatsMonitor: 12 | """ 13 | Base class for a cron-based service loop that updates voice channels in a category with stats. 14 | """ 15 | 16 | def __init__(self, 17 | discord_client, 18 | guild_id: int, 19 | service_entrypoint: Callable, 20 | voice_category: discord.CategoryChannel = None): 21 | self.discord_client = discord_client 22 | self.guild_id = guild_id 23 | self.voice_category = voice_category 24 | self.service_entrypoint = service_entrypoint 25 | 26 | async def run_service(self, interval_seconds: int, override_voice_channel_check: bool = False) -> None: 27 | if not self.voice_category and not override_voice_channel_check: 28 | logging.debug("No voice category set, skipping service run...") 29 | return # No performance voice category set, so don't bother 30 | 31 | while True: 32 | try: 33 | await self.service_entrypoint() 34 | await asyncio.sleep(interval_seconds) 35 | except Exception: 36 | exit(1) # Die on any unhandled exception for this subprocess (i.e. internet connection loss) 37 | 38 | async def edit_stat_voice_channel(self, 39 | voice_channel_settings: VoiceChannel, 40 | stat: Union[int, float, str]) -> None: 41 | channel = None 42 | 43 | if voice_channel_settings.channel_id_set: 44 | channel_id = voice_channel_settings.channel_id 45 | channel = await self.discord_client.fetch_channel(channel_id) 46 | if not channel: 47 | logging.error(f"Could not load channel with ID {channel_id}") 48 | else: 49 | partial_channel_name = voice_channel_settings.prefix 50 | try: 51 | channel = await discord_utils.get_or_create_discord_channel_by_starting_name(client=self.discord_client, 52 | guild_id=self.guild_id, 53 | starting_channel_name=f"{partial_channel_name}", 54 | channel_type=discord.ChannelType.voice, 55 | category=self.voice_category) 56 | except Exception as e: 57 | logging.error(f"Error editing {partial_channel_name} voice channel: {e}") 58 | return 59 | 60 | try: 61 | new_name = voice_channel_settings.build_channel_name(value=stat) 62 | await channel.edit(name=f"{new_name}") 63 | logging.debug(f"Updated {channel.name} successfully") 64 | except Exception as voice_channel_edit_error: 65 | logging.error(f"Error editing {channel.name} voice channel: {voice_channel_edit_error}") 66 | -------------------------------------------------------------------------------- /modules/tautulli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/tautulli/__init__.py -------------------------------------------------------------------------------- /modules/tautulli/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class LibraryType(enum.Enum): 5 | MOVIE = 'movie' 6 | SHOW = 'show' 7 | MUSIC = 'artist' 8 | 9 | @staticmethod 10 | def from_str(value: str) -> 'LibraryType': 11 | if value == 'movie': 12 | return LibraryType.MOVIE 13 | if value == 'show': 14 | return LibraryType.SHOW 15 | if value == 'artist': 16 | return LibraryType.MUSIC 17 | raise ValueError(f"Invalid library type: {value}") 18 | 19 | 20 | class StatMetricType(enum.Enum): 21 | PLAYS = 'plays' 22 | DURATION = 'duration' 23 | 24 | 25 | class HomeStatType(enum.Enum): 26 | TOP_MOVIES = 'top_movies' 27 | POPULAR_MOVIES = 'popular_movies' 28 | TOP_TV = 'top_tv' 29 | POPULAR_TV = 'popular_tv' 30 | TOP_MUSIC = 'top_music' 31 | POPULAR_MUSIC = 'popular_music' 32 | TOP_LIBRARIES = 'top_libraries' 33 | TOP_USERS = 'top_users' 34 | TOP_PLATFORMS = 'top_platforms' 35 | LAST_WATCHED = 'last_watched' 36 | MOST_CONCURRENT = 'most_concurrent' 37 | 38 | 39 | class StatChartType(enum.Enum): 40 | DAILY_BY_MEDIA_TYPE = 'daily_play_count' 41 | # DAILY_BY_STREAM_TYPE = 'daily_by_stream_type' 42 | # DAILY_CONCURRENT_BY_STREAM_TYPE = 'daily_concurrent_by_stream_type' 43 | BY_HOUR_OF_DAY = 'play_count_by_hourofday' 44 | BY_DAY_OF_WEEK = 'play_count_by_dayofweek' 45 | BY_MONTH = 'play_count_by_month' 46 | BY_TOP_10_PLATFORMS = 'top_10_platforms' 47 | BY_TOP_10_USERS = 'top_10_users' 48 | # BY_SOURCE_RESOLUTION = 'by_source_resolution' 49 | # BY_STREAM_RESOLUTION = 'by_stream_resolution' 50 | # BY_PLATFORM_AND_STREAM_TYPE = 'by_platform_and_stream_type' 51 | # BY_USERS_AND_STREAM_TYPE = 'by_users_and_stream_type' 52 | 53 | 54 | class StatChartColors(enum.Enum): 55 | BLACK = "#000000" 56 | WHITE = "#FFFFFF" 57 | MUSIC = '#F06464' 58 | MOVIES = WHITE 59 | TV = '#E5A00D' 60 | BACKGROUND = "#333333" 61 | TEXT = "#A6A6A6" 62 | GRIDLINES = "#D8D5D0" 63 | -------------------------------------------------------------------------------- /modules/tautulli/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/modules/tautulli/models/__init__.py -------------------------------------------------------------------------------- /modules/tautulli/models/activity.py: -------------------------------------------------------------------------------- 1 | from typing import Union, List 2 | 3 | from modules import utils 4 | from modules.emojis import EmojiManager 5 | from modules.tautulli.models.session import Session 6 | from modules.time_manager import TimeManager 7 | 8 | 9 | class Activity: 10 | def __init__(self, activity_data, time_manager: TimeManager, emoji_manager: EmojiManager): 11 | self._data = activity_data 12 | self._time_manager = time_manager 13 | self._emoji_manager = emoji_manager 14 | 15 | @property 16 | def stream_count(self) -> int: 17 | value = self._data.get('stream_count', 0) 18 | try: 19 | return int(value) 20 | except: 21 | return 0 22 | 23 | @property 24 | def transcode_count(self) -> int: 25 | # TODO: Tautulli is reporting the wrong data: 26 | # https://github.com/Tautulli/Tautulli/blob/444b138e97a272e110fcb4364e8864348eee71c3/plexpy/webserve.py#L6000 27 | # Judgment there made by transcode_decision 28 | # We want to consider stream_container_decision 29 | return max([0, [s.is_transcoding for s in self.sessions].count(True)]) 30 | 31 | @property 32 | def total_bandwidth(self) -> Union[str, None]: 33 | value = self._data.get('total_bandwidth', 0) 34 | try: 35 | return utils.human_bitrate(float(value) * 1024) 36 | except: 37 | return None 38 | 39 | @property 40 | def lan_bandwidth(self) -> Union[str, None]: 41 | value = self._data.get('lan_bandwidth', 0) 42 | try: 43 | return utils.human_bitrate(float(value) * 1024) 44 | except: 45 | return None 46 | 47 | @property 48 | def wan_bandwidth(self) -> Union[str, None]: 49 | total = self._data.get('total_bandwidth', 0) 50 | lan = self._data.get('lan_bandwidth', 0) 51 | value = total - lan 52 | try: 53 | return utils.human_bitrate(float(value) * 1024) 54 | except: 55 | return None 56 | 57 | @property 58 | def sessions(self) -> List[Session]: 59 | return [Session(session_data=session_data, time_manager=self._time_manager) for session_data in 60 | self._data.get('sessions', [])] 61 | -------------------------------------------------------------------------------- /modules/tautulli/models/library_item_counts.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | from modules.tautulli.enums import LibraryType 6 | 7 | 8 | class LibraryItemCounts(BaseModel): 9 | library_name: str 10 | library_type: LibraryType 11 | movies: Optional[int] = None 12 | albums: Optional[int] = None 13 | artists: Optional[int] = None 14 | episodes: Optional[int] = None 15 | series: Optional[int] = None 16 | tracks: Optional[int] = None 17 | -------------------------------------------------------------------------------- /modules/tautulli/models/recently_added_media_item.py: -------------------------------------------------------------------------------- 1 | class RecentlyAddedMediaItem: 2 | title: str 3 | poster_url: str 4 | summary: str 5 | library: str 6 | link: str 7 | -------------------------------------------------------------------------------- /modules/tautulli/models/session.py: -------------------------------------------------------------------------------- 1 | import modules.logs as logging 2 | from modules import utils 3 | from modules.emojis import EmojiManager 4 | from modules.time_manager import TimeManager 5 | 6 | 7 | class Session: 8 | def __init__(self, session_data, time_manager: TimeManager): 9 | self._data = session_data 10 | self._time_manager = time_manager 11 | 12 | @property 13 | def duration_milliseconds(self) -> int: 14 | value = self._data.get('duration', 0) 15 | try: 16 | value = int(value) 17 | except: 18 | value = 0 19 | return int(value) 20 | 21 | @property 22 | def location_milliseconds(self) -> int: 23 | value = self._data.get('view_offset', 0) 24 | try: 25 | value = int(value) 26 | except: 27 | value = 0 28 | return int(value) 29 | 30 | @property 31 | def progress_percentage(self) -> int: 32 | if not self.duration_milliseconds: 33 | return 0 34 | return int(self.location_milliseconds / self.duration_milliseconds) 35 | 36 | @property 37 | def progress_marker(self) -> str: 38 | current_progress_min_sec = utils.milliseconds_to_minutes_seconds(milliseconds=self.location_milliseconds) 39 | total_min_sec = utils.milliseconds_to_minutes_seconds(milliseconds=self.duration_milliseconds) 40 | return f"{current_progress_min_sec}/{total_min_sec}" 41 | 42 | @property 43 | def eta(self) -> str: 44 | if not self.duration_milliseconds or not self.location_milliseconds: 45 | return "Unknown" 46 | milliseconds_remaining = self.duration_milliseconds - self.location_milliseconds 47 | return self._time_manager.now_plus_milliseconds_unix_timestamp(milliseconds=milliseconds_remaining) 48 | 49 | @property 50 | def title(self) -> str: 51 | if self._data.get('live'): 52 | return f"{self._data.get('grandparent_title', '')} - {self._data['title']}" 53 | elif self._data['media_type'] == 'episode': 54 | return f"{self._data.get('grandparent_title', '')} - S{self._data.get('parent_title', '').replace('Season ', '').zfill(2)}E{self._data.get('media_index', '').zfill(2)} - {self._data['title']}" 55 | else: 56 | return self._data.get('full_title') 57 | 58 | def get_status_icon(self, emoji_manager: EmojiManager) -> str: 59 | """ 60 | Get icon for a stream state 61 | :return: emoji icon 62 | """ 63 | return emoji_manager.get_emoji(key=self._data.get('state', "")) 64 | 65 | def get_type_icon(self, emoji_manager: EmojiManager) -> str: 66 | key = self._data.get('media_type', "") 67 | if self._data.get('live'): 68 | key = 'live' 69 | emoji = emoji_manager.get_emoji(key=key) 70 | if not emoji: 71 | logging.debug( 72 | "New media_type to pick icon for: {}: {}".format(self._data['title'], self._data['media_type'])) 73 | return '🎁' 74 | return emoji 75 | 76 | @property 77 | def id(self) -> str: 78 | return self._data['session_id'] 79 | 80 | @property 81 | def username(self) -> str: 82 | return self._data['username'] 83 | 84 | @property 85 | def friendly_name(self) -> str: 86 | return self._data['friendly_name'] 87 | 88 | @property 89 | def product(self) -> str: 90 | return self._data['product'] 91 | 92 | @property 93 | def player(self) -> str: 94 | return self._data['player'] 95 | 96 | @property 97 | def quality_profile(self) -> str: 98 | return self._data['quality_profile'] 99 | 100 | @property 101 | def bandwidth(self) -> str: 102 | value = self._data.get('bandwidth', 0) 103 | try: 104 | value = int(value) 105 | except: 106 | value = 0 107 | return utils.human_bitrate(float(value) * 1024) 108 | 109 | @property 110 | def is_transcoding(self) -> bool: 111 | return self.stream_container_decision == 'transcode' 112 | 113 | @property 114 | def transcoding_stub(self) -> str: 115 | return ' (Transcode)' if self.is_transcoding else '' 116 | 117 | @property 118 | def stream_container_decision(self) -> str: 119 | return self._data['stream_container_decision'] 120 | -------------------------------------------------------------------------------- /modules/tautulli/models/stats.py: -------------------------------------------------------------------------------- 1 | class PlayStatsCategoryData: 2 | def __init__(self, category_name: str, x_axis: list, values: list): 3 | self.category_name: str = category_name 4 | self.x_axis: list = x_axis 5 | self.values: list = values 6 | 7 | 8 | class PlayStats: 9 | def __init__(self, data: dict): 10 | self._data: dict = data 11 | self.categories: list = data.get('categories', []) 12 | self._series: list[dict] = data.get('series', []) 13 | 14 | def _get_category_data(self, category_name: str) -> PlayStatsCategoryData: 15 | raise NotImplementedError 16 | 17 | @property 18 | def formatted_data(self) -> dict: 19 | return { 20 | 'tv_shows': self.tv_shows, 21 | 'movies': self.movies, 22 | 'music': self.music 23 | } 24 | 25 | @property 26 | def tv_shows(self) -> PlayStatsCategoryData: 27 | return self._get_category_data(category_name='TV') 28 | 29 | @property 30 | def movies(self) -> PlayStatsCategoryData: 31 | return self._get_category_data(category_name='Movies') 32 | 33 | @property 34 | def music(self) -> PlayStatsCategoryData: 35 | return self._get_category_data(category_name='Music') 36 | 37 | 38 | class PlayCountStats(PlayStats): 39 | def __init__(self, data: dict): 40 | super().__init__(data=data) 41 | 42 | def _get_category_data(self, category_name: str) -> PlayStatsCategoryData: 43 | for series in self._series: 44 | if series.get('name', None) == category_name: 45 | return PlayStatsCategoryData( 46 | category_name=category_name, 47 | x_axis=self.categories, 48 | values=series.get('data', []) 49 | ) 50 | 51 | return PlayStatsCategoryData( 52 | category_name=category_name, 53 | x_axis=self.categories, 54 | values=[] 55 | ) 56 | 57 | 58 | class PlayDurationStats(PlayStats): 59 | def __init__(self, data: dict): 60 | super().__init__(data=data) 61 | 62 | def _get_category_data(self, category_name: str) -> PlayStatsCategoryData: 63 | for series in self._series: 64 | if series.get('name', None) == category_name: 65 | return PlayStatsCategoryData( 66 | category_name=category_name, 67 | x_axis=self.categories, 68 | values=series.get('data', []) 69 | ) 70 | 71 | return PlayStatsCategoryData( 72 | category_name=category_name, 73 | x_axis=self.categories, 74 | values=[] 75 | ) 76 | -------------------------------------------------------------------------------- /modules/text_manager.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from pydantic import BaseModel 4 | 5 | from modules import statics, utils 6 | from modules.emojis import EmojiManager 7 | from modules.time_manager import TimeManager 8 | from modules.utils import limit_text_length 9 | 10 | 11 | class TextManager(BaseModel): 12 | """ 13 | Manages text formatting and anonymization. 14 | """ 15 | hide_usernames: bool 16 | hide_player_names: bool 17 | hide_platforms: bool 18 | hide_quality: bool 19 | hide_bandwidth: bool 20 | hide_transcoding: bool 21 | hide_progress: bool 22 | hide_eta: bool 23 | use_friendly_names: bool 24 | time_manager: TimeManager 25 | 26 | def _session_user_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]: 27 | if self.hide_usernames: 28 | return None 29 | 30 | emoji = emoji_manager.get_emoji(key="person") 31 | username = session.friendly_name if self.use_friendly_names else session.username 32 | stub = f"""{emoji} {utils.bold(username)}""" 33 | 34 | return stub 35 | 36 | def _session_player_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]: 37 | if self.hide_platforms and self.hide_player_names: 38 | return None 39 | 40 | emoji = emoji_manager.get_emoji(key="device") 41 | player = None if self.hide_player_names else session.player 42 | product = None if self.hide_platforms else session.product 43 | 44 | stub = f"""{emoji}""" 45 | if player is not None: 46 | stub += f""" {utils.bold(player)}""" 47 | # Only optionally show product if player is shown. 48 | if product is not None: 49 | stub += f""" ({product})""" 50 | 51 | return stub 52 | 53 | def _session_details_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]: 54 | if self.hide_quality and self.hide_bandwidth and self.hide_transcodingd: 55 | return None 56 | 57 | quality_profile = None if self.hide_quality else session.quality_profile 58 | bandwidth = None if self.hide_bandwidth else session.bandwidth 59 | transcoding = None if self.hide_transcoding else session.transcoding_stub 60 | 61 | emoji = emoji_manager.get_emoji(key="resolution") 62 | stub = f"""{emoji}""" 63 | if quality_profile is not None: 64 | stub += f""" {utils.bold(quality_profile)}""" 65 | # Only optionally show bandwidth if quality profile is shown. 66 | if bandwidth is not None: 67 | stub += f""" ({bandwidth})""" 68 | if transcoding is not None: 69 | stub += f"""{transcoding}""" 70 | 71 | return stub 72 | 73 | def _session_progress_message(self, session, emoji_manager: EmojiManager) -> Union[str, None]: 74 | if self.hide_progress: 75 | return None 76 | 77 | emoji = emoji_manager.get_emoji(key="progress") 78 | progress = session.progress_marker 79 | stub = f"""{emoji} {utils.bold(progress)}""" 80 | if not self.hide_eta: 81 | eta = session.eta 82 | stub += f""" (ETA: {eta})""" 83 | 84 | return stub 85 | 86 | def session_title(self, session, session_number: int, emoji_manager: EmojiManager) -> str: 87 | emoji = emoji_manager.emoji_from_stream_number(number=session_number) 88 | icon = session.get_status_icon(emoji_manager=emoji_manager) 89 | media_type_icon = session.get_type_icon(emoji_manager=emoji_manager) 90 | title = session.title 91 | title = limit_text_length(text=title, limit=statics.MAX_EMBED_FIELD_NAME_LENGTH) 92 | return f"""{emoji} | {icon} {media_type_icon} *{title}*""" 93 | 94 | def session_body(self, session, emoji_manager: EmojiManager) -> str: 95 | user_message = self._session_user_message(session=session, emoji_manager=emoji_manager) 96 | player_message = self._session_player_message(session=session, emoji_manager=emoji_manager) 97 | details_message = self._session_details_message(session=session, emoji_manager=emoji_manager) 98 | progress_message = self._session_progress_message(session=session, emoji_manager=emoji_manager) 99 | 100 | stubs = [user_message, player_message, details_message, progress_message] 101 | stubs = [stub for stub in stubs if stub is not None] 102 | return "\n".join(stubs) 103 | 104 | def overview_footer(self, no_connection: bool, activity, emoji_manager: EmojiManager) -> str: 105 | if no_connection or activity is None: 106 | return f"{utils.bold('Connection lost.')}" 107 | 108 | if activity.stream_count == 0: 109 | return "" 110 | 111 | stream_count = activity.stream_count 112 | stream_count_word = utils.make_plural(word='stream', count=stream_count) 113 | overview_message = f"""{stream_count} {stream_count_word}""" 114 | 115 | if activity.transcode_count > 0 and not self.hide_transcoding: 116 | transcode_count = activity.transcode_count 117 | transcode_count_word = utils.make_plural(word='transcode', count=transcode_count) 118 | overview_message += f""" ({transcode_count} {transcode_count_word})""" 119 | 120 | if activity.total_bandwidth and not self.hide_bandwidth: 121 | bandwidth_emoji = emoji_manager.get_emoji(key='bandwidth') 122 | bandwidth = activity.total_bandwidth 123 | overview_message += f""" @ {bandwidth_emoji} {bandwidth}""" 124 | if activity.lan_bandwidth: 125 | lan_bandwidth_emoji = emoji_manager.get_emoji(key='home') 126 | lan_bandwidth = activity.lan_bandwidth 127 | overview_message += f""" {lan_bandwidth_emoji} {lan_bandwidth}""" 128 | 129 | return overview_message 130 | -------------------------------------------------------------------------------- /modules/time_manager.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import discord.utils 4 | from pydantic import BaseModel 5 | 6 | from modules import utils 7 | 8 | 9 | class TimeManager(BaseModel): 10 | def now(self) -> datetime: 11 | return discord.utils.utcnow() 12 | 13 | def now_unix_timestamp(self) -> str: 14 | _timestamp = int(self.now().timestamp()) 15 | return utils.timestamp(ts=_timestamp) 16 | 17 | def now_plus_milliseconds(self, milliseconds: int) -> datetime: 18 | return utils.now_plus_milliseconds(milliseconds) 19 | 20 | def now_plus_milliseconds_unix_timestamp(self, milliseconds: int) -> str: 21 | _timestamp = int(self.now_plus_milliseconds(milliseconds).timestamp()) 22 | return utils.timestamp(ts=_timestamp) 23 | -------------------------------------------------------------------------------- /modules/versioning.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Union 3 | 4 | import objectrest 5 | 6 | import modules.logs as logging 7 | from consts import ( 8 | GITHUB_REPO, 9 | GITHUB_REPO_MASTER_BRANCH 10 | ) 11 | from modules.statics import ( 12 | get_version, 13 | is_git, 14 | get_sha_hash, 15 | ) 16 | 17 | 18 | def _get_latest_github_release() -> Union[str, None]: 19 | logging.debug('Retrieving latest version information from GitHub') 20 | 21 | url = f'https://api.github.com/repos/{GITHUB_REPO}/releases/latest' 22 | data = objectrest.get_json(url) 23 | 24 | return data.get('tag_name', None) # "tag_name" is the version number (e.g. 2.0.0), not the "name" (e.g. "v2.0.0") 25 | 26 | 27 | def _newer_github_release_available(current_version: str) -> bool: 28 | latest_version = _get_latest_github_release() 29 | if latest_version is None: 30 | return False 31 | 32 | return latest_version != current_version 33 | 34 | 35 | def _get_latest_github_commit() -> Union[str, None]: 36 | logging.debug('Retrieving latest commit information from GitHub') 37 | 38 | url = f'https://api.github.com/repos/{GITHUB_REPO}/commits/{GITHUB_REPO_MASTER_BRANCH}' 39 | data = objectrest.get_json(url) 40 | sha: Union[str, None] = data.get('sha', None) 41 | 42 | if not sha: 43 | return None 44 | 45 | return get_sha_hash(sha=sha) 46 | 47 | 48 | def _newer_github_commit_available(current_commit_hash: str) -> bool: 49 | latest_commit = _get_latest_github_commit() 50 | if latest_commit is None: 51 | return False 52 | 53 | return latest_commit != current_commit_hash 54 | 55 | 56 | def newer_version_available() -> bool: 57 | current_version = get_version() 58 | if is_git(): 59 | return _newer_github_commit_available(current_commit_hash=current_version) 60 | else: 61 | return _newer_github_release_available(current_version=current_version) 62 | 63 | 64 | class VersionChecker: 65 | def __init__(self, enable: bool): 66 | self.enable = enable 67 | self._new_version_available = False 68 | 69 | async def monitor_for_new_version(self): 70 | while True: 71 | try: 72 | self._new_version_available = newer_version_available() 73 | if self._new_version_available: 74 | logging.debug(f"New version available") 75 | await asyncio.sleep(60 * 60) # Check for new version every hour 76 | except Exception: 77 | exit(1) # Die on any unhandled exception for this subprocess (i.e. internet connection loss) 78 | 79 | def is_new_version_available(self) -> bool: 80 | if not self.enable: 81 | return False 82 | 83 | return self._new_version_available 84 | -------------------------------------------------------------------------------- /modules/webhooks/__init__.py: -------------------------------------------------------------------------------- 1 | from modules.webhooks.tautulli_recently_added import RecentlyAddedWebhook, RecentlyAddedWebhookData 2 | -------------------------------------------------------------------------------- /modules/webhooks/tautulli_recently_added.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Union, Optional 3 | 4 | from pydantic import Field, BaseModel 5 | from tautulli.tools.webhooks import TautulliWebhook, TautulliWebhookTrigger 6 | 7 | 8 | class RecentlyAddedWebhookData(BaseModel): 9 | """ 10 | Data from a configured webhook for recently added media 11 | """ 12 | media_type: Optional[str] = None 13 | library_name: Optional[str] = None 14 | title: Optional[str] = None 15 | year_: Optional[str] = Field(None, alias='year') 16 | duration_: Optional[str] = Field(None, alias='duration') 17 | tagline: Optional[str] = None 18 | summary: Optional[str] = None 19 | studio: Optional[str] = None 20 | directors_: Optional[str] = Field(None, alias='directors') 21 | actors_: Optional[str] = Field(None, alias='actors') 22 | genres_: Optional[str] = Field(None, alias='genres') 23 | plex_id: Optional[str] = None 24 | critic_rating_: Optional[str] = Field(None, alias='critic_rating') 25 | audience_rating_: Optional[str] = Field(None, alias='audience_rating') 26 | poster_url: Optional[str] = None 27 | 28 | @property 29 | def year(self) -> Union[int, None]: 30 | return int(self.year_) if self.year_ else None 31 | 32 | @property 33 | def duration(self) -> Union[int, None]: 34 | """ 35 | Get the duration of the media in minutes 36 | """ 37 | if not self.duration_: 38 | return None 39 | 40 | if ':' not in self.duration_: 41 | return int(self.duration_) 42 | 43 | hours, minutes = self.duration_.split(':') 44 | return int(hours) * 60 + int(minutes) 45 | 46 | @property 47 | def directors(self) -> list[str]: 48 | return self.directors_.split(', ') if self.directors_ else [] 49 | 50 | @property 51 | def actors(self) -> list[str]: 52 | return self.actors_.split(', ') if self.actors_ else [] 53 | 54 | @property 55 | def genres(self) -> list[str]: 56 | return self.genres_.split(', ') if self.genres_ else [] 57 | 58 | @property 59 | def critic_rating(self) -> Union[float, None]: 60 | return float(self.critic_rating_) if self.critic_rating_ else None 61 | 62 | @property 63 | def audience_rating(self) -> Union[float, None]: 64 | return float(self.audience_rating_) if self.audience_rating_ else None 65 | 66 | 67 | # ref: https://github.com/Tautulli/Tautulli/blob/d019efcf911b4806618761c2da48bab7d04031ec/plexpy/notifiers.py#L1148 68 | class RecentlyAddedWebhook(TautulliWebhook): 69 | """ 70 | A recently-added webhook from Tautulli 71 | """ 72 | data: RecentlyAddedWebhookData 73 | 74 | @classmethod 75 | def from_flask_request(cls, request) -> Union["RecentlyAddedWebhook", None]: 76 | """ 77 | Ingest a configured recently-added webhook from a Flask request 78 | 79 | :param request: The incoming Flask request 80 | :return: The processed recently-added webhook, or None if the request could not be processed 81 | """ 82 | try: 83 | body = request.get_json() 84 | except Exception: 85 | # JSON data is stored in the form field 'payload_json' if files are present 86 | # ref: https://github.com/Tautulli/Tautulli/blob/d019efcf911b4806618761c2da48bab7d04031ec/plexpy/notifiers.py#L1225 87 | body = json.loads(request.form.get('payload_json', '{}')) 88 | 89 | if not body: 90 | return None 91 | 92 | data = RecentlyAddedWebhookData(**body) 93 | if not data or not data.title: 94 | return None 95 | 96 | return cls(data=data) 97 | 98 | def _determine_trigger(self, **kwargs: dict) -> Union[TautulliWebhookTrigger, None]: 99 | return TautulliWebhookTrigger.RECENTLY_ADDED 100 | -------------------------------------------------------------------------------- /pm2_keepalive.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | while True: 4 | time.sleep(1) 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py==2.5.* 2 | asyncio~=3.4 3 | tautulli==4.6.*,>=4.6.0.2142 4 | confuse==2.0.1 5 | PyYAML==6.0.* 6 | objectrest~=2.0.0 7 | psutil==5.9.8 8 | emoji==2.11.1 9 | Flask~=3.0.2 10 | matplotlib==3.9.2 11 | SQLAlchemy~=2.0.25 12 | sqlalchemy-utils==0.41.1 13 | pydantic>=2.10.0 -------------------------------------------------------------------------------- /resources/emojis/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/1.png -------------------------------------------------------------------------------- /resources/emojis/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/10.png -------------------------------------------------------------------------------- /resources/emojis/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/2.png -------------------------------------------------------------------------------- /resources/emojis/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/3.png -------------------------------------------------------------------------------- /resources/emojis/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/4.png -------------------------------------------------------------------------------- /resources/emojis/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/5.png -------------------------------------------------------------------------------- /resources/emojis/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/6.png -------------------------------------------------------------------------------- /resources/emojis/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/7.png -------------------------------------------------------------------------------- /resources/emojis/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/8.png -------------------------------------------------------------------------------- /resources/emojis/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/9.png -------------------------------------------------------------------------------- /resources/emojis/buffering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/buffering.png -------------------------------------------------------------------------------- /resources/emojis/episode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/episode.png -------------------------------------------------------------------------------- /resources/emojis/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/error.png -------------------------------------------------------------------------------- /resources/emojis/movie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/movie.png -------------------------------------------------------------------------------- /resources/emojis/paused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/paused.png -------------------------------------------------------------------------------- /resources/emojis/person.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/person.png -------------------------------------------------------------------------------- /resources/emojis/playing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/playing.png -------------------------------------------------------------------------------- /resources/emojis/stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/stopped.png -------------------------------------------------------------------------------- /resources/emojis/track.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwithan8/tauticord/0f08a36473a1a247c76d553986aefc7a7995d317/resources/emojis/track.png -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | from pydantic import ValidationError as PydanticValidationError 5 | 6 | import modules.logs as logging 7 | import modules.tautulli.tautulli_connector as tautulli 8 | from consts import ( 9 | GOOGLE_ANALYTICS_ID, 10 | APP_NAME, 11 | DEFAULT_CONFIG_PATH, 12 | DEFAULT_LOG_DIR, 13 | DEFAULT_DATABASE_PATH, 14 | CONSOLE_LOG_LEVEL, 15 | FILE_LOG_LEVEL, 16 | ) 17 | from migrations.migration_manager import MigrationManager 18 | from modules import versioning 19 | from modules.analytics import GoogleAnalytics 20 | from modules.database.migrations import run_migrations as run_database_migrations_steps 21 | from modules.discord.bot import Bot 22 | from modules.discord.services.library_stats import LibraryStatsMonitor 23 | from modules.discord.services.live_activity import LiveActivityMonitor 24 | from modules.discord.services.performance_stats import PerformanceStatsMonitor 25 | from modules.discord.services.slash_commands import SlashCommandManager 26 | from modules.discord.services.tagged_message import TaggedMessagesManager 27 | from modules.emojis import EmojiManager 28 | from modules.errors import determine_exit_code, TauticordMigrationFailure, TauticordSetupFailure 29 | from modules.settings.config_parser import Config 30 | from modules.statics import ( 31 | splash_logo, 32 | MONITORED_DISK_SPACE_FOLDER, 33 | KEY_RUN_ARGS_CONFIG_PATH, 34 | KEY_RUN_ARGS_LOG_PATH, 35 | KEY_RUN_ARGS_MONITOR_PATH, 36 | KEY_RUN_ARGS_DATABASE_PATH, 37 | ) 38 | 39 | # Parse CLI arguments 40 | parser = argparse.ArgumentParser(description="Tauticord - Discord bot for Tautulli") 41 | 42 | """ 43 | Bot will use config, in order: 44 | 1. Explicit config file path provided as CLI argument, if included, or 45 | 2. Default config file path, if exists, or 46 | 3. Environmental variables 47 | """ 48 | parser.add_argument("-c", "--config", help="Path to config file", default=DEFAULT_CONFIG_PATH) 49 | parser.add_argument("-l", "--log", help="Log file directory", default=DEFAULT_LOG_DIR) 50 | parser.add_argument("-d", "--database", help="Path to database file", default=DEFAULT_DATABASE_PATH) 51 | parser.add_argument("-u", "--usage", help="Path to directory to monitor for disk usage", 52 | default=MONITORED_DISK_SPACE_FOLDER) 53 | args = parser.parse_args() 54 | 55 | config_directory = os.path.dirname(args.config) 56 | if config_directory == "": 57 | config_directory = "./" 58 | 59 | 60 | def run_with_potential_exit_on_error(func): 61 | def wrapper(*args, **kwargs): 62 | try: 63 | return func(*args, **kwargs) 64 | except Exception as e: 65 | logging.fatal(f"Fatal error occurred. Shutting down: {e}") 66 | exit_code = determine_exit_code(exception=e) 67 | logging.fatal(f"Exiting with code {exit_code}") 68 | exit(exit_code) 69 | 70 | return wrapper 71 | 72 | 73 | @run_with_potential_exit_on_error 74 | def set_up_logging(): 75 | logging.init(app_name=APP_NAME, console_log_level=CONSOLE_LOG_LEVEL, log_to_file=True, log_file_dir=args.log, 76 | file_log_level=FILE_LOG_LEVEL) 77 | logging.info(splash_logo()) 78 | 79 | 80 | @run_with_potential_exit_on_error 81 | def run_config_migrations() -> None: 82 | # Run configuration migrations 83 | migration_manager = MigrationManager( 84 | migration_data_directory=os.path.join(config_directory, "migration_data"), 85 | config_directory=config_directory, 86 | logs_directory=args.log) 87 | if not migration_manager.run_migrations(): 88 | raise TauticordMigrationFailure("Migrations failed.") 89 | 90 | 91 | @run_with_potential_exit_on_error 92 | def run_database_migrations(database_path: str) -> None: 93 | # Run database migrations 94 | if not run_database_migrations_steps(database_path=database_path): 95 | raise TauticordMigrationFailure("Database migrations failed.") 96 | 97 | 98 | @run_with_potential_exit_on_error 99 | def set_up_configuration() -> Config: 100 | # Set up configuration 101 | kwargs = { 102 | KEY_RUN_ARGS_MONITOR_PATH: args.usage, 103 | KEY_RUN_ARGS_CONFIG_PATH: config_directory, 104 | KEY_RUN_ARGS_LOG_PATH: args.log, 105 | KEY_RUN_ARGS_DATABASE_PATH: args.database, 106 | } 107 | try: 108 | return Config(config_path=f"{args.config}", **kwargs) 109 | except PydanticValidationError as e: # Redirect Pydantic validation errors during config parsing 110 | raise TauticordSetupFailure(f"Configuration error: {e}") from e 111 | 112 | 113 | @run_with_potential_exit_on_error 114 | def set_up_analytics(config: Config) -> GoogleAnalytics: 115 | # Set up analytics 116 | return GoogleAnalytics(analytics_id=GOOGLE_ANALYTICS_ID, 117 | anonymous_ip=True, 118 | do_not_track=not config.extras.allow_analytics) 119 | 120 | 121 | @run_with_potential_exit_on_error 122 | def set_up_tautulli_connection(config: Config, 123 | analytics: GoogleAnalytics, 124 | database_path: str) -> tautulli.TautulliConnector: 125 | # Set up Tautulli connection 126 | return tautulli.TautulliConnector( 127 | tautulli_settings=config.tautulli, 128 | display_settings=config.display, 129 | stats_settings=config.stats, 130 | database_path=database_path, 131 | analytics=analytics, 132 | ) 133 | 134 | 135 | @run_with_potential_exit_on_error 136 | def set_up_emoji_manager() -> EmojiManager: 137 | # Set up emoji manager 138 | return EmojiManager() 139 | 140 | 141 | @run_with_potential_exit_on_error 142 | def set_up_discord_bot(config: Config, 143 | tautulli_connector: tautulli.TautulliConnector, 144 | emoji_manager: EmojiManager, 145 | analytics: GoogleAnalytics) -> Bot: 146 | # Set up Discord bot 147 | services = [ 148 | # Services start in the order they are added 149 | SlashCommandManager( 150 | enable_slash_commands=config.discord.enable_slash_commands, 151 | guild_id=config.discord.server_id, 152 | tautulli=tautulli_connector, 153 | emoji_manager=emoji_manager, 154 | admin_ids=config.discord.admin_ids, 155 | ), 156 | TaggedMessagesManager( 157 | guild_id=config.discord.server_id, 158 | emoji_manager=emoji_manager, 159 | admin_ids=config.discord.admin_ids, 160 | ), 161 | LiveActivityMonitor( 162 | tautulli_connector=tautulli_connector, 163 | discord_settings=config.discord, 164 | tautulli_settings=config.tautulli, 165 | stats_settings=config.stats, 166 | emoji_manager=emoji_manager, 167 | analytics=analytics, 168 | version_checker=versioning.VersionChecker(enable=config.extras.update_reminders) 169 | ), 170 | LibraryStatsMonitor( 171 | tautulli_connector=tautulli_connector, 172 | discord_settings=config.discord, 173 | stats_settings=config.stats, 174 | emoji_manager=emoji_manager, 175 | analytics=analytics, 176 | ), 177 | PerformanceStatsMonitor( 178 | tautulli_connector=tautulli_connector, 179 | discord_settings=config.discord, 180 | stats_settings=config.stats, 181 | run_args_settings=config.run_args, 182 | emoji_manager=emoji_manager, 183 | analytics=analytics, 184 | ), 185 | ] 186 | logging.info("Setting up Discord connection") 187 | return Bot( 188 | bot_token=config.discord.bot_token, 189 | services=services, 190 | discord_status_settings=config.discord.status_message_settings, 191 | guild_id=config.discord.server_id, 192 | emoji_manager=emoji_manager, 193 | ) 194 | 195 | 196 | @run_with_potential_exit_on_error 197 | def start(discord_bot: Bot) -> None: 198 | # Connect the bot to Discord (last step, since it will block and trigger all the sub-services) 199 | discord_bot.connect() 200 | 201 | 202 | if __name__ == "__main__": 203 | set_up_logging() 204 | run_config_migrations() 205 | run_database_migrations(database_path=args.database) 206 | _config: Config = set_up_configuration() 207 | _analytics: GoogleAnalytics = set_up_analytics(config=_config) 208 | _emoji_manager: EmojiManager = set_up_emoji_manager() 209 | _tautulli_connector: tautulli.TautulliConnector = set_up_tautulli_connection(config=_config, 210 | analytics=_analytics, 211 | database_path=args.database) 212 | _discord_bot: Bot = set_up_discord_bot(config=_config, 213 | tautulli_connector=_tautulli_connector, 214 | emoji_manager=_emoji_manager, 215 | analytics=_analytics) 216 | start(discord_bot=_discord_bot) 217 | -------------------------------------------------------------------------------- /templates/tauticord.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tauticord 4 | nwithan8/tauticord:latest 5 | https://hub.docker.com/r/nwithan8/tauticord 6 | 7 | latest 8 | Latest stable release 9 | 10 | bridge 11 | bash 12 | false 13 | https://github.com/nwithan8/tauticord/issues 14 | https://github.com/nwithan8/tauticord 15 | Tauticord is a Discord bot that displays live data from Tautulli, including stream summaries, bandwidth and library statistics. 16 | MediaApp:Other Productivity: Tools: MediaServer Status:Stable 17 | https://raw.githubusercontent.com/nwithan8/tauticord/master/documentation/images/icon.png 18 | https://raw.githubusercontent.com/nwithan8/tauticord/master/documentation/images/banner.png 19 | https://raw.githubusercontent.com/nwithan8/tauticord/master/documentation/images/embed.png 20 | https://raw.githubusercontent.com/nwithan8/tauticord/master/documentation/images/libraries.png 21 | https://raw.githubusercontent.com/nwithan8/tauticord/master/documentation/images/recently_added.png 22 | https://raw.githubusercontent.com/nwithan8/tauticord/master/documentation/images/most_active_libraries.png 23 | https://raw.githubusercontent.com/nwithan8/tauticord/master/documentation/images/graphs_play_duration_day_of_week.png 24 | https://raw.githubusercontent.com/nwithan8/unraid_templates/main/templates/tauticord.xml 25 | 26 | https://github.com/nwithan8 27 | 28 | Please see announcements for potential breaking changes: https://github.com/nwithan8/tauticord/blob/master/documentation/ANNOUNCEMENTS.md 29 | 30 | ### 2024-11-30 31 | 32 | Add port configuration for API server 33 | 34 | ### 2024-03-30 - BREAKING CHANGE 35 | 36 | Configuration via environment variables has been removed. Please see the [GitHub page](https://github.com/nwithan8/tauticord) for more information. 37 | 38 | ### 2024-03-25 - BREAKING CHANGE 39 | 40 | Template now uses dropdowns for boolean values. Please see the [GitHub page](https://github.com/nwithan8/tauticord/blob/master/documentation/ANNOUNCEMENTS.md#adding-dropdown-options-in-unraid-community-applications-template) for more information. 41 | 42 | 8283 43 | /mnt/user/appdata/tauticord/config 44 | /mnt/user/appdata/tauticord/logs 45 | /mnt/user/appdata/tauticord/monitor 46 | 47 | --------------------------------------------------------------------------------