Full Code of huxuan/iptvtools for AI

xuan.hu/fix-update-template 2d15ff1bba01 cached
71 files
119.9 KB
31.9k tokens
33 symbols
1 requests
Download .txt
Repository: huxuan/iptvtools
Branch: xuan.hu/fix-update-template
Commit: 2d15ff1bba01
Files: 71
Total size: 119.9 KB

Directory structure:
gitextract_nwhxt54e/

├── .copier-answers.yml
├── .devcontainer/
│   ├── Dockerfile
│   ├── Dockerfile.dockerignore
│   └── devcontainer.json
├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── ci.yml
│       ├── commitlint.yml
│       ├── delete-untagged-packages.yml
│       ├── devcontainer.yml
│       ├── readthedocs-preview.yml
│       ├── release.yml
│       ├── renovate.yml
│       └── semantic-release.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── .releaserc.json
├── .renovaterc.json
├── .vscode/
│   ├── extensions.json
│   └── settings.json
├── LICENSE
├── Makefile
├── README.md
├── config.json
├── docs/
│   ├── advanced/
│   │   ├── cicd.md
│   │   ├── dev-containers.md
│   │   ├── index.md
│   │   └── partial-dev-env.md
│   ├── api/
│   │   ├── index.md
│   │   └── settings.md
│   ├── cli/
│   │   ├── filter.md
│   │   ├── index.md
│   │   └── iptvtools.md
│   ├── conf.py
│   ├── development/
│   │   ├── cleanup-dev-env.md
│   │   ├── commit.md
│   │   ├── git-workflow.md
│   │   ├── index.md
│   │   ├── setup-dev-env.md
│   │   └── tests.md
│   ├── index.md
│   ├── management/
│   │   ├── index.md
│   │   ├── init.md
│   │   ├── release.md
│   │   ├── settings.md
│   │   └── update.md
│   ├── reports/
│   │   ├── coverage/
│   │   │   └── index.md
│   │   ├── index.md
│   │   └── mypy/
│   │       └── index.md
│   └── usage/
│       ├── filter.md
│       └── index.md
├── pyproject.toml
├── scripts/
│   └── generate-coverage-badge.sh
├── src/
│   └── iptvtools/
│       ├── __init__.py
│       ├── cli.py
│       ├── config.py
│       ├── constants/
│       │   ├── __init__.py
│       │   ├── defaults.py
│       │   ├── helps.py
│       │   ├── patterns.py
│       │   └── tags.py
│       ├── exceptions.py
│       ├── models.py
│       ├── parsers.py
│       ├── py.typed
│       ├── settings.py
│       └── utils.py
└── tests/
    ├── __init__.py
    ├── cli_test.py
    ├── pkg_test.py
    └── settings_test.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .copier-answers.yml
================================================
_commit: v0.0.66
_src_path: gh:serious-scaffold/ss-python
author_email: i@huxuan.org
author_name: huxuan
copyright_holder: huxuan
copyright_license: MIT License
copyright_year: 2019-2025
coverage_threshold: 0
default_py: '3.12'
development_status: Beta
max_py: '3.13'
min_py: '3.10'
module_name: iptvtools
organization_name: huxuan.org
package_name: iptvtools
platforms:
- macos
- linux
project_description: A set of scripts that help to better IPTV experience.
project_keywords: iptvtools, iptvtools-cli, m3u filter
project_name: IPTVTools
readme_content: "## Features\n\nScripts currently provided:\n\n- iptvtools-cli filter\n\
    \  - Merge from different resources.\n  - Check the tcp/udp connectivity.\n  -\
    \ Filter by custom criteria, e.g. resolution.\n  - Match with templates and EPG.\n\
    \  - Format the url with UDPxy if provided.\n  - Unify channels' titles.\n\nFeatures\
    \ planned on the road:\n\n- [ ] Scan certain ip and port range to find new channels.\n\
    - [ ] Establish a lightweight database for routine maintenance.\n\nBesides, all\
    \ scripts should be lightweight and able to keep running regularly after proper\
    \ configuration.\n\nLast but not least, any ideas, comments and suggestions are\
    \ welcome!\n\n## Prerequisites\n\nTo filter by stream information, e.g., resolution/height,\
    \ [ffmpeg](https://www.ffmpeg.org/) (or [ffprobe](https://www.ffmpeg.org/ffprobe.html)\
    \ more precisely) is needed, please install according to the [documentation](https://www.ffmpeg.org/download.html).\n\
    \n## Installation\n\nIt is recommended to manage iptvtools via [pipx](https://github.com/pypa/pipx):\n\
    \n```shell\npipx install iptvtools\n```\n\n## Usage\n\nPlease refer to the [documentation](https://iptvtools.readthedocs.io/)\
    \ while some useful information in [wiki](https://github.com/huxuan/iptvtools/wiki)."
repo_name: iptvtools
repo_namespace: huxuan
repo_platform: github


================================================
FILE: .devcontainer/Dockerfile
================================================
# syntax=docker/dockerfile:1

ARG PYTHON_VERSION=3.12

########################################################################################
# Dev image is used for development and cicd.
########################################################################################

FROM python:${PYTHON_VERSION} AS dev

# NOTE: python docker image has env `PYTHON_VERSION` but with patch version.
# ARG is used here for temporary override without changing the original env.
ARG PYTHON_VERSION

# Config Python
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONHASHSEED=0
ENV PYTHONUNBUFFERED=1

# Config pipx
ENV PIPX_HOME=/usr/local/pipx
ENV PIPX_BIN_DIR=/usr/local/bin
ENV PIPX_DEFAULT_PYTHON=/usr/local/bin/python

# renovate: depName=debian_12/bash-completion
ARG BASH_COMPLETION_VERSION="1:2.11-6"
# renovate: depName=debian_12/pipx
ARG PIPX_VERSION="1.1.0-1"
# renovate: depName=debian_12/sudo
ARG SUDO_VERSION="1.9.13p3-1+deb12u1"
# renovate: depName=debian_12/vim
ARG VIM_VERSION="2:9.0.1378-2"

# Install system dependencies and override pipx with a newer version
RUN apt-get update && apt-get install -y --no-install-recommends \
    bash-completion="${BASH_COMPLETION_VERSION}" \
    pipx="${PIPX_VERSION}" \
    sudo="${SUDO_VERSION}" \
    vim="${VIM_VERSION}" \
    && pipx install pipx==1.7.1 \
    && apt-get purge -y --autoremove pipx \
    && apt-get clean -y \
    && rm -rf /var/lib/apt/lists/* \
    && hash -r

# Install prerequisites
RUN --mount=source=Makefile,target=Makefile \
    make prerequisites

# Create a non-root user with sudo permission
ARG USERNAME=iptvtools
ARG USER_UID=1000
ARG USER_GID=$USER_UID

RUN groupadd --gid $USER_GID $USERNAME \
    && useradd --create-home --uid $USER_UID --gid $USER_GID $USERNAME -s /bin/bash \
    && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
    && chmod 0440 /etc/sudoers.d/$USERNAME

# Set permission for related folders
RUN chown -R $USER_UID:$USER_GID $PIPX_HOME $PIPX_BIN_DIR

# Set default working directory
WORKDIR /workspace

########################################################################################
# Build image is an intermediate image used for building the project.
########################################################################################

FROM dev AS build

# Install dependencies and project into the local packages directory.
ARG SCM_VERSION
RUN --mount=source=README.md,target=README.md \
    --mount=source=pdm.lock,target=pdm.lock \
    --mount=source=pyproject.toml,target=pyproject.toml \
    --mount=source=src,target=src,rw \
    mkdir __pypackages__ && SETUPTOOLS_SCM_PRETEND_VERSION_FOR_IPTVTOOLS=${SCM_VERSION} pdm sync --prod --no-editable

########################################################################################
# Prod image is used for deployment and distribution.
########################################################################################

FROM python:${PYTHON_VERSION}-slim AS prod

# NOTE: python docker image has env `PYTHON_VERSION` but with patch version.
# ARG is used here for temporary override without changing the original env.
ARG PYTHON_VERSION

# Config Python
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONHASHSEED=0
ENV PYTHONUNBUFFERED=1

# Retrieve packages from build stage.
ENV PYTHONPATH=/workspace/pkgs
COPY --from=build /workspace/__pypackages__/${PYTHON_VERSION}/lib /workspace/pkgs

# Retrieve executables from build stage.
COPY --from=build /workspace/__pypackages__/${PYTHON_VERSION}/bin/* /usr/local/bin/

# Set command to run the cli by default.
ENTRYPOINT ["iptvtools-cli"]


================================================
FILE: .devcontainer/Dockerfile.dockerignore
================================================
*
.*
!/Makefile
!/README.md
!/pdm.lock
!/pyproject.toml
!/src/


================================================
FILE: .devcontainer/devcontainer.json
================================================
{
    "customizations": {
        // Configure extensions specific to VS Code.
        "vscode": {
            "extensions": [
                "DavidAnson.vscode-markdownlint",
                "ExecutableBookProject.myst-highlight",
                "charliermarsh.ruff",
                "ms-python.mypy-type-checker",
                "ms-python.python",
                "richie5um2.vscode-sort-json",
                "streetsidesoftware.code-spell-checker"
            ]
        }
    },
    "image": "ghcr.io/huxuan/iptvtools/dev:py3.12",
    // Force the image update to ensure the latest version which might be a bug.
    // Reference: https://github.com/microsoft/vscode-remote-release/issues/9391
    "initializeCommand": "docker pull ghcr.io/huxuan/iptvtools/dev:py3.12",
    // Use a targeted named volume for .venv folder to improve disk performance.
    // Reference: https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
    "mounts": [
        "source=${localWorkspaceFolderBasename}-venv,target=${containerWorkspaceFolder}/.venv,type=volume"
    ],
    "name": "iptvtools",
    // Set proper permission for the .venv folder when the container created.
    "postCreateCommand": "sudo chown iptvtools:iptvtools .venv",
    // Prepare the development environment when the container starts.
    "postStartCommand": "make dev",
    // Use the non-root user in the container.
    "remoteUser": "iptvtools"
}


================================================
FILE: .github/FUNDING.yml
================================================
github:
  - huxuan


================================================
FILE: .github/workflows/ci.yml
================================================
name: CI

on:
  pull_request:
  push:
    branches:
      - main

concurrency:
  cancel-in-progress: true
  group: ${{ github.workflow }}-${{ github.ref }}

jobs:
  ci:
    if: ${{ !cancelled() && ! failure() }}
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          fetch-depth: 0
      - name: Set up PDM
        uses: pdm-project/setup-pdm@b2472ca4258a9ea3aee813980a0100a2261a42fc # v4.2
        with:
          cache: true
          python-version: ${{ matrix.python-version }}
          version: 2.22.3
          cache-dependency-path: |
            ./pdm.dev.lock
            ./pdm.lock
      - run: env | sort
      - run: make prerequisites
      - run: make dev
      - run: make lint test doc build
    strategy:
      matrix:
        os:
          # renovate: github-runner
          - macos-14
          # renovate: github-runner
          - ubuntu-24.04
        python-version:
          - '3.10'
          - '3.11'
          - '3.12'
          - '3.13'


================================================
FILE: .github/workflows/commitlint.yml
================================================
name: CommitLint
concurrency:
  cancel-in-progress: true
  group: ${{ github.workflow }}-${{ github.ref }}
jobs:
  commitlint:
    container:
      image: commitlint/commitlint:19.7.1@sha256:af27e796a83d69dfeb6307b1734942e959543eecd18736585db13a83ae1ca307
    runs-on: ubuntu-24.04
    steps:
      - run: env | sort
      - name: Validate the latest commit message with commitlint
        if: github.event_name == 'push'
        run: echo "${{ github.event.head_commit.message }}" | npx commitlint -x @commitlint/config-conventional
      - name: Validate pull request title with commitlint
        if: github.event_name == 'pull_request'
        run: echo "${{ github.event.pull_request.title }}" | npx commitlint -x @commitlint/config-conventional
on:
  pull_request:
    types:
      - opened
      - synchronize
      - reopened
      - edited
  push:
    branches:
      - main


================================================
FILE: .github/workflows/delete-untagged-packages.yml
================================================
name: Delete Untagged Packages

on:
  schedule:
    - cron: "0 2 * * 0"
  workflow_dispatch: null

permissions:
  packages: write

jobs:
  delete-untagged-packages:
    runs-on: ubuntu-24.04
    steps:
      - name: Delete untagged dev-cache packages
        uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0
        with:
          package-name: "iptvtools/dev-cache"
          package-type: "container"
          delete-only-untagged-versions: "true"
      - name: Delete untagged development packages
        uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0
        with:
          package-name: "iptvtools/dev"
          package-type: "container"
          delete-only-untagged-versions: "true"
      - name: Delete untagged production packages
        uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0
        with:
          package-name: "iptvtools"
          package-type: "container"
          delete-only-untagged-versions: "true"


================================================
FILE: .github/workflows/devcontainer.yml
================================================
name: DevContainer

on:
  pull_request:
    paths:
      - .devcontainer/Dockerfile
      - .devcontainer/Dockerfile.dockerignore
      - .github/workflows/devcontainer.yml
      - Makefile
  push:
    branches:
      - main
    paths:
      - .devcontainer/Dockerfile
      - .devcontainer/Dockerfile.dockerignore
      - .github/workflows/devcontainer.yml
      - Makefile
  workflow_dispatch: null

concurrency:
  cancel-in-progress: true
  group: ${{ github.workflow }}-${{ github.ref }}

jobs:
  dev-container-publish:
    permissions:
      packages: write
    runs-on: ubuntu-24.04
    steps:
      - run: env | sort
      - name: Checkout repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Set up authentication
        run: docker login -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} ghcr.io
      - name: Set up BuildKit
        run: |
          docker context create builder
          docker buildx create builder --name container --driver docker-container --use
          docker buildx inspect --bootstrap --builder container
      - name: Build the dev container
        run: |
          docker buildx build . \
            --build-arg PYTHON_VERSION=${{ matrix.python-version }} \
            --cache-from type=registry,ref=ghcr.io/${{ github.repository }}/dev-cache:py${{ matrix.python-version }} \
            --file .devcontainer/Dockerfile \
            --load \
            --tag ghcr.io/${{ github.repository }}/dev:py${{ matrix.python-version }} \
            --target dev
      - name: Test the dev container
        run: |
          docker run --rm \
            -e CI=true \
            -v ${PWD}:/workspace \
            ghcr.io/${{ github.repository }}/dev:py${{ matrix.python-version }} \
            make dev lint test doc build
      - name: Build the prod container
        run: |
          docker buildx build . \
            --build-arg PYTHON_VERSION=${{ matrix.python-version }} \
            --file .devcontainer/Dockerfile \
            --load \
            --tag ghcr.io/${{ github.repository }}:py${{ matrix.python-version }} \
            --target prod
      - name: Test the prod container
        run: docker run --rm ghcr.io/${{ github.repository }}:py${{ matrix.python-version }}
      - name: Push the dev container
        if: github.event_name != 'pull_request'
        run: |
          docker buildx build . \
            --build-arg PYTHON_VERSION=${{ matrix.python-version }} \
            --cache-to type=registry,ref=ghcr.io/${{ github.repository }}/dev-cache:py${{ matrix.python-version }},mode=max \
            --file .devcontainer/Dockerfile \
            --push \
            --tag ghcr.io/${{ github.repository }}/dev:py${{ matrix.python-version }} \
            --target dev
    strategy:
      matrix:
        python-version:
          - '3.10'
          - '3.11'
          - '3.12'
          - '3.13'


================================================
FILE: .github/workflows/readthedocs-preview.yml
================================================
name: Read the Docs Pull Request Preview
concurrency:
  cancel-in-progress: true
  group: ${{ github.workflow }}-${{ github.ref }}
jobs:
  documentation-links:
    runs-on: ubuntu-24.04
    steps:
      - name: Add Read the Docs preview's link to pull request
        uses: readthedocs/actions/preview@b8bba1484329bda1a3abe986df7ebc80a8950333 # v1.5
        with:
          project-slug: iptvtools
on:
  pull_request_target:
    types:
      - opened
    paths:
      - .github/workflows/readthedocs-preview.yml
      - .readthedocs.yaml
      - Makefile
      - README.md
      - docs/**
      - pdm.dev.lock
      - pdm.lock
permissions:
  pull-requests: write


================================================
FILE: .github/workflows/release.yml
================================================
name: Release

on:
  release:
    types:
      - published

concurrency:
  cancel-in-progress: true
  group: ${{ github.workflow }}-${{ github.ref }}

jobs:
  pages-build:
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          fetch-depth: 0
      - name: Set up PDM
        uses: pdm-project/setup-pdm@b2472ca4258a9ea3aee813980a0100a2261a42fc # v4.2
        with:
          cache: true
          python-version: '3.12'
          version: 2.22.3
          cache-dependency-path: |
            ./pdm.dev.lock
            ./pdm.lock
      - run: env | sort
      - run: make dev-doc
      - run: make doc
      - name: Upload pages artifact
        uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
        with:
          path: public
  pages:
    needs:
      - pages-build
    permissions:
      id-token: write
      pages: write
    runs-on: ubuntu-24.04
    steps:
      - id: deployment
        name: Deploy to GitHub Pages
        uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
  container-publish:
    permissions:
      packages: write
    runs-on: ubuntu-24.04
    steps:
      - run: env | sort
      - name: Checkout repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Set up authentication
        run: docker login -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} ghcr.io
      - name: Set up BuildKit
        run: |
          docker context create builder
          docker buildx create builder --name container --driver docker-container --use
          docker buildx inspect --bootstrap --builder container
      - name: Build the dev container
        run: |
          docker buildx build . \
            --build-arg PYTHON_VERSION=${{ matrix.python-version }} \
            --cache-from type=registry,ref=ghcr.io/${{ github.repository }}/dev-cache:py${{ matrix.python-version }} \
            --file .devcontainer/Dockerfile \
            --load \
            --tag ghcr.io/${{ github.repository }}/dev:py${{ matrix.python-version }} \
            --tag ghcr.io/${{ github.repository }}/dev:py${{ matrix.python-version }}-${{ github.ref_name }} \
            --target dev
      - name: Test the dev container
        run: |
          docker run --rm \
            -e CI=true \
            -v ${PWD}:/workspace \
            ghcr.io/${{ github.repository }}/dev:py${{ matrix.python-version }} \
            make dev lint test doc build
      - name: Build the prod container
        run: |
          docker buildx build . \
            --build-arg SCM_VERSION=${{ github.ref_name }} \
            --build-arg PYTHON_VERSION=${{ matrix.python-version }} \
            --file .devcontainer/Dockerfile \
            --load \
            --tag ghcr.io/${{ github.repository }}:py${{ matrix.python-version }} \
            --tag ghcr.io/${{ github.repository }}:py${{ matrix.python-version }}-${{ github.ref_name }} \
            --target prod
      - name: Test the prod container
        run: docker run --rm ghcr.io/${{ github.repository }}:py${{ matrix.python-version }}
      - name: Push the dev container
        run: |
          docker buildx build . \
            --build-arg PYTHON_VERSION=${{ matrix.python-version }} \
            --cache-to type=registry,ref=ghcr.io/${{ github.repository }}/dev-cache:py${{ matrix.python-version }},mode=max \
            --file .devcontainer/Dockerfile \
            --push \
            --tag ghcr.io/${{ github.repository }}/dev:py${{ matrix.python-version }} \
            --tag ghcr.io/${{ github.repository }}/dev:py${{ matrix.python-version }}-${{ github.ref_name }} \
            --target dev
      - name: Push the prod container
        run: |
          docker buildx build . \
            --build-arg SCM_VERSION=${{ github.ref_name }} \
            --build-arg PYTHON_VERSION=${{ matrix.python-version }} \
            --file .devcontainer/Dockerfile \
            --push \
            --tag ghcr.io/${{ github.repository }}:py${{ matrix.python-version }} \
            --tag ghcr.io/${{ github.repository }}:py${{ matrix.python-version }}-${{ github.ref_name }} \
            --target prod
    strategy:
      matrix:
        python-version:
          - '3.10'
          - '3.11'
          - '3.12'
          - '3.13'
  package-publish:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Set up PDM
        uses: pdm-project/setup-pdm@b2472ca4258a9ea3aee813980a0100a2261a42fc # v4.2
        with:
          cache: true
          python-version: '3.12'
          version: 2.22.3
          cache-dependency-path: |
            ./pdm.dev.lock
            ./pdm.lock
      - run: env | sort
      - env:
          PDM_PUBLISH_PASSWORD: ${{ secrets.PDM_PUBLISH_PASSWORD }}
          PDM_PUBLISH_USERNAME: ${{ vars.PDM_PUBLISH_USERNAME || '__token__' }}
        run: make publish


================================================
FILE: .github/workflows/renovate.yml
================================================
name: Renovate
jobs:
  renovate:
    container:
      env:
        LOG_LEVEL: debug
        RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^find", "^pdm"]'
        RENOVATE_BRANCH_PREFIX: renovate-github/
        RENOVATE_ENABLED: ${{ vars.RENOVATE_ENABLED || true }}
        RENOVATE_ENABLED_MANAGERS: '["copier", "github-actions", "pep621", "pre-commit", "regex"]'
        RENOVATE_OPTIMIZE_FOR_DISABLED: "true"
        RENOVATE_PLATFORM: github
        RENOVATE_REPOSITORIES: '["${{ github.repository }}"]'
        RENOVATE_REPOSITORY_CACHE: enabled
      image: ghcr.io/renovatebot/renovate:39.156.1@sha256:33153a313777d4640e37dccdac5ec67263c00edd5d470748599eba25790dea93
      options: "--user root"
    runs-on: ubuntu-24.04
    steps:
      - run: env | sort
      - id: generate-token
        name: Generate a token with GitHub App if App ID exists
        if: vars.BOT_APP_ID
        uses: actions/create-github-app-token@0d564482f06ca65fa9e77e2510873638c82206f2 # v1.11.5
        with:
          app-id: ${{ vars.BOT_APP_ID }}
          private-key: ${{ secrets.BOT_PRIVATE_KEY }}
      - name: Warn if use GITHUB_TOKEN
        run: |
          if [ -z "${{ steps.generate-token.outputs.token || secrets.PAT }}" ]; then
            echo "# :warning: GITHUB_TOKEN is used for renovate" >> $GITHUB_STEP_SUMMARY
            echo "The GITHUB_TOKEN is used instead of a bot token or PAT and will not emit the checks for the pull requests." >> $GITHUB_STEP_SUMMARY
          fi
      - name: Warn if RENOVATE_GIT_AUTHOR is set while using GitHub App token
        if: steps.generate-token.outputs.token && vars.RENOVATE_GIT_AUTHOR
        run: |
          echo "# :warning: `RENOVATE_GIT_AUTHOR` is set explicitly while using GitHub App token" >> $GITHUB_STEP_SUMMARY
          echo "Generally, Renovate automatically detects the git author and email using the token. However, explicitly setting the `RENOVATE_GIT_AUTHOR` will override this behavior." >> $GITHUB_STEP_SUMMARY
      - name: Run Renovate
        env:
          RENOVATE_GIT_AUTHOR: ${{ vars.RENOVATE_GIT_AUTHOR }}
          RENOVATE_PLATFORM_COMMIT: ${{ steps.generate-token.outputs.token && true || false }}
          RENOVATE_TOKEN: ${{ steps.generate-token.outputs.token || secrets.PAT || secrets.GITHUB_TOKEN }}
        run: |
          if [ -z "$RENOVATE_TOKEN" ]; then
            echo "RENOVATE_TOKEN is not properly configured, skipping ..."
          else
            renovate $RENOVATE_EXTRA_FLAG
          fi
on:
  schedule:
    # * is a special character in YAML so you have to quote this string
    - cron: "*/15 0-3 * * 1"
  workflow_dispatch: null


================================================
FILE: .github/workflows/semantic-release.yml
================================================
name: Semantic Release

on:
  workflow_run:
    workflows: [CI]
    types: [completed]
    branches: [main]

jobs:
  semantic-release:
    name: Semantic Release
    runs-on: ubuntu-24.04
    # Ensure CI workflow is succeeded and avoid semantic release on forked repository
    if: github.event.workflow_run.conclusion == 'success' && github.repository == 'huxuan/iptvtools'
    permissions:
      contents: write
      id-token: write
      issues: write
      pull-requests: write
    steps:
      - id: generate-token
        name: Generate a token with GitHub App if App ID exists
        if: vars.BOT_APP_ID
        uses: actions/create-github-app-token@0d564482f06ca65fa9e77e2510873638c82206f2 # v1.11.5
        with:
          app-id: ${{ vars.BOT_APP_ID }}
          private-key: ${{ secrets.BOT_PRIVATE_KEY }}
      - name: Warn if use GITHUB_TOKEN
        run: |
          if [ -z "${{ steps.generate-token.outputs.token || secrets.PAT }}" ]; then
            echo "# :warning: GITHUB_TOKEN is used for semantic-release" >> $GITHUB_STEP_SUMMARY
            echo "The GITHUB_TOKEN is used instead of a bot token or PAT and will not emit the released publish event for the released workflow." >> $GITHUB_STEP_SUMMARY
          fi
      - name: Checkout repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: Setup Node.js
        uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
        with:
          node-version: 'lts/*'
      - name: Semantic Release
        env:
          GITHUB_TOKEN: ${{ steps.generate-token.outputs.token || secrets.PAT || secrets.GITHUB_TOKEN }}
        run: >
          npx
          --package conventional-changelog-conventionalcommits@8.0.0
          --package semantic-release@24.2.1
          semantic-release


================================================
FILE: .gitignore
================================================
# Custom
*.m3u
*.swp
.DS_Store
Pipfile
public

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# poetry
#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock

# pdm
#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
#   in version control.
#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#  and can be added to the global gitignore or merged into this file.  For a more nuclear
#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/


================================================
FILE: .pre-commit-config.yaml
================================================
default_install_hook_types:
  - post-checkout
  - post-merge
  - post-rewrite
  - pre-push
default_stages:
  - manual
  - pre-push
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: check-added-large-files
      - id: check-docstring-first
      - id: check-merge-conflict
        args:
          - '--assume-in-merge'
      - id: check-toml
      - id: check-xml
      - id: check-yaml
      - id: end-of-file-fixer
      - id: forbid-new-submodules
      - id: mixed-line-ending
      - id: name-tests-test
      - id: no-commit-to-branch
        stages:
          - pre-push
      - id: sort-simple-yaml
        files: .pre-commit-config.yaml
      - id: trailing-whitespace
  - repo: https://github.com/renovatebot/pre-commit-hooks
    rev: 39.156.1
    hooks:
      - id: renovate-config-validator
  - repo: local
    hooks:
      - id: pdm-sync
        name: pdm-sync
        entry: pdm sync
        language: python
        stages:
          - post-checkout
          - post-merge
          - post-rewrite
        always_run: true
        pass_filenames: false
      - id: pdm-dev-sync
        name: pdm-dev-sync
        entry: pdm sync --lockfile pdm.dev.lock
        language: python
        stages:
          - post-checkout
          - post-merge
          - post-rewrite
        always_run: true
        pass_filenames: false
      - id: pdm-lock-check
        name: pdm-lock-check
        entry: pdm lock --check
        language: python
        files: ^pyproject.toml$
        pass_filenames: false
      - id: pdm-dev-lock-check
        name: pdm-dev-lock-check
        entry: pdm lock --check --lockfile pdm.dev.lock
        language: python
        files: ^pyproject.toml$
        pass_filenames: false
      - id: mypy
        name: mypy
        entry: pdm run python -m mypy
        language: system
        types_or:
          - python
          - pyi
        require_serial: true
      - id: ruff
        name: ruff
        entry: ruff check --force-exclude
        language: system
        types_or:
          - python
          - pyi
        require_serial: true
      - id: ruff-format
        name: ruff-format
        entry: ruff format --force-exclude
        language: system
        types_or:
          - python
          - pyi
        require_serial: true
      - id: pyproject-fmt
        name: pyproject-fmt
        entry: pyproject-fmt
        language: python
        files: '(^|/)pyproject\.toml$'
        types:
          - toml
      - id: codespell
        name: codespell
        entry: codespell
        language: python
        types:
          - text
      - id: check-jsonschema
        name: check-jsonschema
        entry: make check-jsonschema
        language: python
        files: (?x)^(
          \.github/workflows/[^/]+|
          \.gitlab-ci\.yml|
          \.gitlab/workflows/[^/]+|
          \.readthedocs\.yaml|
          \.renovaterc\.json
          )$
        pass_filenames: false
      - id: forbidden-files
        name: forbidden files
        entry: found Copier update rejection files; review them and remove them
        language: fail
        files: \.rej$


================================================
FILE: .readthedocs.yaml
================================================
build:
  apt_packages:
    - pipx
  jobs:
    post_checkout:
      - git fetch --unshallow || true
      # Cancel building pull requests when there aren't changed in the related files and folders.
      # If there are no changes (git diff exits with 0) we force the command to return with 183.
      # This is a special exit code on Read the Docs that will cancel the build immediately.
      # Ref: https://docs.readthedocs.io/en/stable/build-customization.html#cancel-build-based-on-a-condition
      - |
        if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- \
          .github/workflows/readthedocs-preview.yml \
          .readthedocs.yaml \
          Makefile \
          README.md \
          docs/ \
          pdm.dev.lock \
          pdm.lock;
        then
          exit 183;
        fi
    post_system_dependencies:
      - env | sort
    pre_create_environment:
      - PIPX_BIN_DIR=$READTHEDOCS_VIRTUALENV_PATH/bin pipx install pdm==2.22.3
    post_install:
      - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH make dev-doc
    post_build:
      - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH make mypy doc-coverage
  os: ubuntu-24.04
  tools:
    python: "3.12"
sphinx:
  configuration: docs/conf.py
  fail_on_warning: true
version: 2


================================================
FILE: .releaserc.json
================================================
{
    "plugins": [
        [
            "@semantic-release/commit-analyzer",
            {
                "releaseRules": [
                    {
                        "breaking": true,
                        "release": "major"
                    },
                    {
                        "type": "build",
                        "release": false
                    },
                    {
                        "type": "chore",
                        "release": false
                    },
                    {
                        "type": "ci",
                        "release": false
                    },
                    {
                        "type": "docs",
                        "release": false
                    },
                    {
                        "type": "feat",
                        "release": "minor"
                    },
                    {
                        "type": "fix",
                        "release": "patch"
                    },
                    {
                        "type": "perf",
                        "release": "patch"
                    },
                    {
                        "type": "refactor",
                        "release": false
                    },
                    {
                        "type": "revert",
                        "release": "patch"
                    },
                    {
                        "type": "style",
                        "release": false
                    },
                    {
                        "type": "test",
                        "release": false
                    },
                    {
                        "scope": "*major-release*",
                        "release": "major"
                    },
                    {
                        "scope": "*minor-release*",
                        "release": "minor"
                    },
                    {
                        "scope": "*patch-release*",
                        "release": "patch"
                    },
                    {
                        "scope": "*no-release*",
                        "release": false
                    }
                ]
            }
        ],
        [
            "@semantic-release/release-notes-generator",
            {
                "presetConfig": {
                    "types": [
                        {
                            "type": "build",
                            "section": "Build"
                        },
                        {
                            "type": "chore",
                            "section": "Chores"
                        },
                        {
                            "type": "ci",
                            "section": "Continuous Integration"
                        },
                        {
                            "type": "docs",
                            "section": "Documentation"
                        },
                        {
                            "type": "feat",
                            "section": "Features"
                        },
                        {
                            "type": "fix",
                            "section": "Bug Fixes"
                        },
                        {
                            "type": "perf",
                            "section": "Performance"
                        },
                        {
                            "type": "refactor",
                            "section": "Refactor"
                        },
                        {
                            "type": "revert",
                            "section": "Reverts"
                        },
                        {
                            "type": "style",
                            "section": "Styles"
                        },
                        {
                            "type": "test",
                            "section": "Tests"
                        }
                    ]
                }
            }
        ],
        "@semantic-release/github"
    ],
    "preset": "conventionalcommits"
}


================================================
FILE: .renovaterc.json
================================================
{
    "$schema": "https://docs.renovatebot.com/renovate-schema.json",
    "constraints": {
        "copier": "==9.4.1",
        "pdm": "2.22.3",
        "python": "==3.12"
    },
    "customManagers": [
        {
            "customType": "regex",
            "datasourceTemplate": "pypi",
            "description": "Match Python packages installed with pip/pipx",
            "fileMatch": [
                "^Makefile$",
                "^README\\.md$",
                "^\\.devcontainer/Dockerfile$",
                "^\\.github/workflows/.+\\.yml$",
                "^\\.gitlab/workflows/.+\\.yml$",
                "^\\.readthedocs\\.yaml$",
                "^\\.renovaterc\\.json$",
                "^docs/.+\\.md$"            ],
            "matchStrings": [
                "pip install.* (?<depName>.*?)(\\[.*?\\])?==(?<currentValue>.*?)[\"\n]",
                "pipx install( --force)? (?<depName>.*?)(\\[.*?\\])?==(?<currentValue>.*?)\\s",
                "pipx list --short \\| grep -q \"(?<depName>.*?)(\\[.*?\\])? (?<currentValue>.*?)\""
            ]
        },
        {
            "customType": "regex",
            "datasourceTemplate": "repology",
            "depTypeTemplate": "debian",
            "description": "Match debian packages installed in Dockerfiles",
            "fileMatch": [
                "^\\.devcontainer\\/Dockerfile$"            ],
            "matchStrings": [
                "# renovate: depName=(?<depName>.*?)\nARG .*?_VERSION=\"(?<currentValue>.*)\"\n"
            ],
            "versioningTemplate": "deb"
        },
        {
            "customType": "regex",
            "datasourceTemplate": "pypi",
            "depNameTemplate": "pdm",
            "description": "Match pdm version specified in setup-pdm GitHub Action",
            "fileMatch": [
                "^\\.github/workflows/.+\\.yml$"            ],
            "matchStrings": [
                "uses: pdm-project/setup-pdm[\\s\\S]+?\\sversion: (?<currentValue>.*)\n"
            ]
        },
        {
            "customType": "regex",
            "datasourceTemplate": "pypi",
            "depNameTemplate": "pdm",
            "description": "Match pdm version specified in the renovate constraints",
            "fileMatch": [
                "^\\.renovaterc\\.json$"            ],
            "matchStrings": [
                "\"pdm\": \"(?<currentValue>.*)\""
            ]
        },
        {
            "customType": "regex",
            "datasourceTemplate": "github-runners",
            "depTypeTemplate": "github-runner",
            "description": "Match GitHub runner defined in GitHub Actions matrix strategy",
            "fileMatch": [
                "^\\.github/workflows/.+\\.yml$",
                "^template/.*\\.github.*/workflows/.+\\.yml(\\.jinja)?$"
            ],
            "matchStrings": [
                "# renovate: github-runner\n\\s+- (os: )?(?<depName>.*?)-(?<currentValue>.*)\n"
            ],
            "versioningTemplate": "docker"
        },
        {
            "customType": "regex",
            "datasourceTemplate": "npm",
            "description": "Match npm packages used with npx",
            "fileMatch": [
                "^\\.github/workflows/.+\\.yml$",
                "^\\.gitlab/workflows/.+\\.yml$"            ],
            "matchStrings": [
                "--package (?<depName>.+?)@(?<currentValue>.+?)\\s"
            ],
            "versioningTemplate": "docker"
        }
    ],
    "extends": [
        "config:best-practices",
        ":enablePreCommit",
        ":maintainLockFilesWeekly",
        ":semanticCommitTypeAll(build)"
    ],
    "packageRules": [
        {
            "description": "Update lock files for development dependencies",
            "matchUpdateTypes": [
                "lockFileMaintenance"
            ],
            "postUpgradeTasks": {
                "commands": [
                    "pdm update --lockfile pdm.dev.lock --no-default --dev --no-sync --update-eager"
                ]
            }
        },
        {
            "description": "Group pdm Python package and version specified in setup-pdm GitHub Action",
            "groupName": "pdm",
            "matchDatasources": [
                "github-tags",
                "pypi"
            ],
            "matchDepNames": [
                "pdm"
            ]
        },
        {
            "description": "Group renovate docker tag and pre-commit-hooks tag",
            "groupName": "renovate",
            "matchDatasources": [
                "docker",
                "github-tags"
            ],
            "matchDepNames": [
                "ghcr.io/renovatebot/renovate",
                "renovatebot/pre-commit-hooks"
            ]
        },
        {
            "description": "Group debian packages to avoid failure when multiple packages are outdated",
            "groupName": "debian packages",
            "matchDepTypes": [
                "debian"
            ]
        }
    ]
}


================================================
FILE: .vscode/extensions.json
================================================
{
  "recommendations": [
    "DavidAnson.vscode-markdownlint",
    "ExecutableBookProject.myst-highlight",
    "charliermarsh.ruff",
    "ms-python.mypy-type-checker",
    "ms-python.python",
    "ms-vscode-remote.remote-containers",
    "richie5um2.vscode-sort-json",
    "streetsidesoftware.code-spell-checker"
  ]
}


================================================
FILE: .vscode/settings.json
================================================
{
  "[jsonc]": {
    "editor.defaultFormatter": "vscode.json-language-features"
  },
  "[markdown]": {
    "editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
  },
  "[python]": {
    "editor.codeActionsOnSave": {
      "source.fixAll.ruff": "explicit",
      "source.organizeImports.ruff": "explicit"
    },
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true
  },
  "cSpell.words": [
    "autofix",
    "automodule",
    "cobertura",
    "codespell",
    "commitlint",
    "conventionalcommits",
    "datasource",
    "deepclean",
    "deflist",
    "devcontainer",
    "devcontainers",
    "elif",
    "endmacro",
    "epub",
    "furo",
    "genindex",
    "huxuan",
    "interruptible",
    "JPKXI",
    "maxdepth",
    "modindex",
    "mypy",
    "noninteractive",
    "pathjoin",
    "pipenv",
    "pipx",
    "pycache",
    "pydantic",
    "pypi",
    "pyproject",
    "pytest",
    "Quickstart",
    "renovatebot",
    "repology",
    "setuptools",
    "softprops",
    "sphinxcontrib",
    "titlesonly",
    "toctree",
    "unshallow",
    "viewcode"
  ],
  "editor.codeActionsOnSave": {
    "source.fixAll": "explicit"
  },
  "editor.formatOnSave": true,
  "editor.rulers": [
    88
  ],
  "files.exclude": {
    "**/*.egg-info": true,
    "**/.coverage": true,
    "**/.mypy_cache": true,
    "**/.pdm-build": true,
    "**/.pytest_cache": true,
    "**/.ruff_cache": true,
    "**/.venv": true,
    "**/Pipfile*": true,
    "**/__pycache__": true,
    "**/_build": true,
    "**/coverage.xml": true,
    "**/htmlcov": true
  },
  "files.insertFinalNewline": true,
  "files.trimFinalNewlines": true,
  "files.trimTrailingWhitespace": true,
  "myst.preview.extensions": [
    "dollarmath",
    "deflist"
  ],
  "sortJSON.contextMenu": {
    "sortJSONAlphaNum": false,
    "sortJSONAlphaNumReverse": false,
    "sortJSONKeyLength": false,
    "sortJSONKeyLengthReverse": false,
    "sortJSONReverse": false,
    "sortJSONType": false,
    "sortJSONTypeReverse": false,
    "sortJSONValues": false,
    "sortJSONValuesReverse": false
  }
}


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2019-2025 huxuan

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: Makefile
================================================
.PHONY: clean deepclean install dev prerequisites mypy ruff ruff-format pyproject-fmt codespell lint pre-commit test-run test build publish doc-watch doc-build doc-coverage doc
########################################################################################
# Variables
########################################################################################

# Documentation target directory, will be adapted to specific folder for readthedocs.
PUBLIC_DIR := $(shell [ "$$READTHEDOCS" = "True" ] && echo "$${READTHEDOCS_OUTPUT}html" || echo "public")

# Determine the Python version used by pipx.
PIPX_PYTHON_VERSION := $(shell `pipx environment --value PIPX_DEFAULT_PYTHON` -c "from sys import version_info; print(f'{version_info.major}.{version_info.minor}')")

########################################################################################
# Development Environment Management
########################################################################################

# Remove common intermediate files.
clean:
	-rm -rf \
		$(PUBLIC_DIR) \
		.coverage \
		.mypy_cache \
		.pdm-build \
		.pdm-python \
		.pytest_cache \
		.ruff_cache \
		Pipfile* \
		__pypackages__ \
		build \
		coverage.xml \
		dist
	find . -name '*.egg-info' -print0 | xargs -0 rm -rf
	find . -name '*.pyc' -print0 | xargs -0 rm -f
	find . -name '*.swp' -print0 | xargs -0 rm -f
	find . -name '.DS_Store' -print0 | xargs -0 rm -f
	find . -name '__pycache__' -print0 | xargs -0 rm -rf

# Remove pre-commit hook, virtual environment alongside intermediate files.
deepclean: clean
	if command -v pre-commit > /dev/null 2>&1; then pre-commit uninstall; fi
	if command -v pdm >/dev/null 2>&1 && pdm venv list | grep -q in-project ; then pdm venv remove --yes in-project >/dev/null 2>&1; fi

# Install the package in editable mode.
install:
	pdm install --prod

# Install the package in editable mode with specific optional dependencies.
dev-%: install
	pdm install --lockfile pdm.dev.lock --no-default --dev --group $*

# Prepare the development environment.
# Install the package in editable mode with all optional dependencies and pre-commit hook.
dev: install
	pdm install --lockfile pdm.dev.lock --no-default --dev
	if [ "$(CI)" != "true" ] && command -v pre-commit > /dev/null 2>&1; then pre-commit install; fi

# Lock both prod and dev dependencies.
lock:
	pdm lock --prod --update-reuse-installed
	pdm lock --lockfile pdm.dev.lock --no-default --dev --update-reuse-installed

# Install standalone tools
prerequisites:
	pipx list --short | grep -q "check-jsonschema 0.31.1" || pipx install --force check-jsonschema==0.31.1
	pipx list --short | grep -q "codespell 2.4.1" || pipx install --force codespell[toml]==2.4.1
	pipx list --short | grep -q "pdm 2.22.3" || pipx install --force pdm==2.22.3
	pipx list --short | grep -q "pre-commit 4.1.0" || pipx install --force pre-commit==4.1.0
	pipx list --short | grep -q "pyproject-fmt 2.5.0" || pipx install --force pyproject-fmt==2.5.0
	pipx list --short | grep -q "ruff 0.9.4" || pipx install --force ruff==0.9.4
	pipx list --short | grep -q "watchfiles 1.0.4" || pipx install --force watchfiles==1.0.4

########################################################################################
# Lint and pre-commit
########################################################################################

# Check lint with mypy.
mypy:
	pdm run python -m mypy . --html-report $(PUBLIC_DIR)/reports/mypy

# Lint with ruff.
ruff:
	ruff check .

# Format with ruff.
ruff-format:
	ruff format --check .

# Check lint with pyproject-fmt.
pyproject-fmt:
	pyproject-fmt pyproject.toml

# Check lint with codespell.
codespell:
	codespell

# Check jsonschema with check-jsonschema.
check-jsonschema:
	check-jsonschema --builtin-schema vendor.github-workflows .github/workflows/*.yml
	check-jsonschema --builtin-schema vendor.readthedocs .readthedocs.yaml
	check-jsonschema --builtin-schema vendor.renovate --regex-variant nonunicode .renovaterc.json

# Check lint with all linters.
lint: mypy ruff ruff-format pyproject-fmt codespell check-jsonschema

# Run pre-commit with autofix against all files.
pre-commit:
	pre-commit run --all-files --hook-stage manual

########################################################################################
# Test
########################################################################################

# Clean and run test with coverage.
test-run:
	pdm run python -m coverage erase
	pdm run python -m coverage run -m pytest

# Generate coverage report for terminal and xml.
test: test-run
	pdm run python -m coverage report
	pdm run python -m coverage xml

########################################################################################
# Package
########################################################################################

# Build the package.
build:
	pdm build

# Publish the package.
publish:
	pdm publish

########################################################################################
# Documentation
########################################################################################

# Generate documentation with auto build when changes happen.
doc-watch:
	pdm run python -m http.server --directory public &
	watchfiles "make doc-build" docs src README.md

# Build documentation only from src.
doc-build:
	pdm run sphinx-build --fail-on-warning --write-all docs $(PUBLIC_DIR)

# Generate html coverage reports with badge.
doc-coverage: test-run
	pdm run python -m coverage html -d $(PUBLIC_DIR)/reports/coverage
	pdm run bash scripts/generate-coverage-badge.sh $(PUBLIC_DIR)/_static/badges

# Generate all documentation with reports.
doc: doc-build mypy doc-coverage

########################################################################################
# End
########################################################################################


================================================
FILE: README.md
================================================
# IPTVTools

A set of scripts that help to better IPTV experience.

[![CI](https://github.com/huxuan/iptvtools/actions/workflows/ci.yml/badge.svg)](https://github.com/huxuan/iptvtools/actions/workflows/ci.yml)
[![CommitLint](https://github.com/huxuan/iptvtools/actions/workflows/commitlint.yml/badge.svg)](https://github.com/huxuan/iptvtools/actions/workflows/commitlint.yml)
[![DevContainer](https://github.com/huxuan/iptvtools/actions/workflows/devcontainer.yml/badge.svg)](https://github.com/huxuan/iptvtools/actions/workflows/devcontainer.yml)
[![Release](https://github.com/huxuan/iptvtools/actions/workflows/release.yml/badge.svg)](https://github.com/huxuan/iptvtools/actions/workflows/release.yml)
[![Renovate](https://github.com/huxuan/iptvtools/actions/workflows/renovate.yml/badge.svg)](https://github.com/huxuan/iptvtools/actions/workflows/renovate.yml)
[![Semantic Release](https://github.com/huxuan/iptvtools/actions/workflows/semantic-release.yml/badge.svg)](https://github.com/huxuan/iptvtools/actions/workflows/semantic-release.yml)
[![Coverage](https://img.shields.io/endpoint?url=https://huxuan.github.io/iptvtools/_static/badges/coverage.json)](https://huxuan.github.io/iptvtools/reports/coverage)
[![Release](https://img.shields.io/github/v/release/huxuan/iptvtools)](https://github.com/huxuan/iptvtools/releases)
[![PyPI](https://img.shields.io/pypi/v/iptvtools)](https://pypi.org/project/iptvtools/)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/iptvtools)](https://pypi.org/project/iptvtools/)
[![GitHub](https://img.shields.io/github/license/huxuan/iptvtools)](https://github.com/huxuan/iptvtools/blob/main/LICENSE)

[![pdm-managed](https://img.shields.io/badge/pdm-managed-blueviolet)](https://pdm-project.org)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
[![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydantic/pydantic/5697b1e4c4a9790ece607654e6c02a160620c7e1/docs/badge/v2.json)](https://pydantic.dev)
[![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-orange.json)](https://github.com/copier-org/copier)
[![Serious Scaffold Python](https://img.shields.io/endpoint?url=https://serious-scaffold.github.io/ss-python/_static/badges/logo.json)](https://serious-scaffold.github.io/ss-python)
[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/huxuan/iptvtools)

> [!IMPORTANT]
> _IPTVTools_ is in the **Beta** phase.
> Changes and potential instability should be anticipated.
> Any feedback, comments, suggestions and contributions are welcome!

## Features

Scripts currently provided:

- iptvtools-cli filter
  - Merge from different resources.
  - Check the tcp/udp connectivity.
  - Filter by custom criteria, e.g. resolution.
  - Match with templates and EPG.
  - Format the url with UDPxy if provided.
  - Unify channels' titles.

Features planned on the road:

- [ ] Scan certain ip and port range to find new channels.
- [ ] Establish a lightweight database for routine maintenance.

Besides, all scripts should be lightweight and able to keep running regularly after proper configuration.

Last but not least, any ideas, comments and suggestions are welcome!

## Prerequisites

To filter by stream information, e.g., resolution/height, [ffmpeg](https://www.ffmpeg.org/) (or [ffprobe](https://www.ffmpeg.org/ffprobe.html) more precisely) is needed, please install according to the [documentation](https://www.ffmpeg.org/download.html).

## Installation

It is recommended to manage iptvtools via [pipx](https://github.com/pypa/pipx):

```shell
pipx install iptvtools
```

## Usage

Please refer to the [documentation](https://iptvtools.readthedocs.io/) while some useful information in [wiki](https://github.com/huxuan/iptvtools/wiki).
## 📜 License

MIT License, for more details, see the [LICENSE](https://github.com/huxuan/iptvtools/blob/main/LICENSE) file.


================================================
FILE: config.json
================================================
{
    "id_unifiers": {
        "-": "",
        "IPTV": "",
        "北京纪实": "BTV冬奥纪实",
        "BTV北京卫视": "北京卫视",
        "卡酷动画": "BTV卡酷少儿",
        "CETV1": "中国教育1台",
        "CETV2": "中国教育2台",
        "CETV3": "中国教育3台",
        "CETV4": "中国教育4台",
        "纪实频道": "上海纪实"
    },
    "title_unifiers": {
        "4K超清": "北京IPTV 4K超清",
        "BTV冬奥纪实4K": "BTV冬奥纪实",
        "BTV冬奥纪实HDR": "BTV冬奥纪实",
        "CETV4": "中国教育4台",
        "DOGTV": "北京IPTV 萌宠TV",
        "HD": "",
        "卡酷动画": "BTV卡酷少儿",
        "淘Baby": "北京IPTV 淘Baby",
        "淘剧场": "北京IPTV 淘剧场",
        "淘娱乐": "北京IPTV 淘娱乐",
        "淘电影": "北京IPTV 淘电影",
        "电视台": "",
        "高清": "",
        "+": "+"
    }
}


================================================
FILE: docs/advanced/cicd.md
================================================
# CI/CD Configurations

The CI/CD (Continuous Integration and Continuous Delivery) workflows automate various development tasks to ensure project maintainability with minimal human effort. The configuration files are located at `.github/workflows/*.yml` for GitHub and `.gitlab/workflows/*.yml` for GitLab.

## `ci.yml`

The `ci` workflow is the most frequently used workflow, running on all pull/merge requests and changes to the default `main` branch. It performs linting, testing, and builds for the documentation and the package across all supported operation systems and Python versions to ensure everything works as expected.

## `commitlint.yml`

The `commitlint` workflow checks whether the pull/merge request title comply with the <project:/development/commit.md>. This ensures consistent commit history and enable the possibility of automated release pipeline.

## `delete-untagged-packages.yml`

The `delete-untagged-packages` workflow removes untagged packages since GitHub will still keep the package when overridden with the same tag. It helps keep the GitHub Packages clean and tidy.

## `devcontainer.yml`

The `devcontainer` workflow will be triggered by container related changes. It builds and tests the development and production containers and push the development container except during pull/merge requests, ensuring seamless containerized environments.

## `readthedocs-preview.yml`

The `readthedocs-preview` workflow leverage the [readthedocs/actions/preview](https://github.com/readthedocs/actions/tree/v1/preview) to add Read the Docs preview links to the related pull requests. These links make it easy to review documentation changes.

## `release.yml`

The `release` workflow manages the entire publish process, including publishing the documentation, containers and packages. It is triggered by a new release or a release tag. It also ensures all the builds and tests are succeed before completing the release.

## `renovate.yml`

The `renovate` workflow automates the <project:/management/update.md>. It is scheduled to run weekly and will create pull/merges request when there are new versions of the scaffold template, Python packages, GitHub Runners, GitHub Actions, docker images and etc. It keeps the project secure and ensures compatibility with the latest versions.

## `semantic-release.yml`

The `semantic-release` workflow automate the versioning and release process by publishing new releases or new release tags when certain changes are pushed to the default `main` branch. It simplifies the release management while maintaining consistency.


================================================
FILE: docs/advanced/dev-containers.md
================================================
# Development Container

Instead of manually configuring your development environment, [Dev Containers](https://containers.dev/) offer a seamless containerized development experience right out of the box.

## Prerequisites

Before you can use a Dev Container, you will need to install a few components.

1. [Docker Desktop](https://www.docker.com/products/docker-desktop) or an [alternative Docker option](https://code.visualstudio.com/remote/advancedcontainers/docker-options).
1. [Visual Studio Code](https://code.visualstudio.com/).
1. The [Dev Containers extension](vscode:extension/ms-vscode-remote.remote-containers) within VSCode.

## Usage

After installing the prerequisites, you have two main approaches to use a Dev Container. Using [a locally cloned repository](#open-a-locally-cloned-repository-in-a-container) leverages your existing local source code, while [an isolated container volume](#open-the-repository-in-an-isolated-container-volume) creates a separate copy of the repository, which is particularly useful for PR reviews or exploring branches without altering your local environment.

### Open a locally cloned repository in a container

When you open a repository that includes a Dev Container configuration in VS Code, you will receive a prompt to reopen it in the container.

```{image} /_static/images/dev-container-reopen-prompt.png
:alt: Dev Container Reopen Prompt.
```

If you missed the prompt, you can use the **Dev Containers: Reopen in Container** command from the Command Palette to initiate the containerized environment. Here are some frequently used commands:

Dev Containers: Reopen in Container
: Triggers the containerized environment setup upon opening a repository configured for Dev Containers.

Dev Containers: Rebuild Without Cache and Reopen in Container
: Useful for refreshing your environment in case of issues or to update to a newer version.

Dev Containers: Clean Up Dev Containers...
: Deletes stopped Dev Container instances and removes unused volumes, helping maintain a clean development environment.

### Open the repository in an isolated container volume

You may already notice the badge [![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/huxuan/iptvtools) in the [Overview](/index.md) page. You can click the badge or [this link](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/huxuan/iptvtools) to get started. Clicking these links will cause VS Code to automatically install the Dev Containers extension if needed, clone the source code into a container volume, and spin up a dev container for use.

## Reference

For more detailed guidance and advanced usage, explore the following resources:

- [Dev Containers tutorial](https://code.visualstudio.com/docs/devcontainers/tutorial)
- [Developing inside a Container](https://code.visualstudio.com/docs/devcontainers/containers)


================================================
FILE: docs/advanced/index.md
================================================
# Advanced Usage

This section provides recommended best practices for enhancing your development workflow. While not essential, these topics can optimize the project management and development processes.

```{toctree}
dev-containers
partial-dev-env
cicd
```


================================================
FILE: docs/advanced/partial-dev-env.md
================================================
# Partially Set Up Development Environment

In certain cases, it is unnecessary to install all dependencies as well as the pre-commit hook. For example, this can speed up the setup process in CI/CD.

## Minimal installation

Install the project in editable mode with only the necessary dependencies, which is useful for scenarios like deployment.

```bash
make install
```

## Documentation generation

Install the project in editable mode with dependencies related to `doc`,
recommended for scenarios like the documentation generation CI/CD process.

```bash
make dev-doc
```

## Lint check

Install the project in editable mode with dependencies related to `lint`,
recommended for scenarios like the lint CI/CD process.

```bash
make dev-lint
```

## Package build

Install the project in editable mode with dependencies related to `package`,
recommended for scenarios like the package CI/CD process.

```bash
make dev-package
```

## Testing

Install the project in editable mode with dependencies related to `test`,
recommended for scenarios like the test CI/CD process.

```bash
make dev-test
```

## Combination

To install dependencies for `doc` and `lint`, use the following command:

```bash
make dev-doc,lint
```


================================================
FILE: docs/api/index.md
================================================
# API Reference

```{toctree}
:maxdepth: 1
settings
```


================================================
FILE: docs/api/settings.md
================================================
# iptvtools.settings

```{eval-rst}
.. automodule:: iptvtools.settings
```


================================================
FILE: docs/cli/filter.md
================================================
# IPTVTools Filter

```{eval-rst}
.. click:: iptvtools.cli:filter
  :prog: iptvtools-cli filter
  :nested: full
```


================================================
FILE: docs/cli/index.md
================================================
# CLI Reference

```{toctree}
:maxdepth: 1
iptvtools
filter
```


================================================
FILE: docs/cli/iptvtools.md
================================================
# IPTVTools

```{eval-rst}
.. click:: iptvtools.cli:cli
  :prog: iptvtools-cli
  :nested: short
```


================================================
FILE: docs/conf.py
================================================
"""Configuration file for the Sphinx documentation builder.

For the full list of built-in configuration values, see the documentation:
https://www.sphinx-doc.org/en/master/usage/configuration.html
"""

from importlib import metadata

# -- Project information ---------------------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information

author = "huxuan"
copyright = "2019-2025, huxuan"
project = "IPTVTools"
release = metadata.version("iptvtools")
version = ".".join(release.split(".")[:2])


# -- General configuration -------------------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

extensions = [
    "myst_parser",
    "sphinx.ext.autodoc",
    "sphinx.ext.napoleon",
    "sphinx.ext.viewcode",
    "sphinx_click",
    "sphinx_design",
    "sphinxcontrib.autodoc_pydantic",
]
source_suffix = {
    ".rst": "restructuredtext",
    ".md": "markdown",
}
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
templates_path = ["_templates"]
html_theme_options = {
    "announcement": (
        "<em>IPTVTools</em> "
        "is in the <strong>Beta</strong> phase. "
        "Changes and potential instability should be anticipated. "
        "Any feedback, comments, suggestions and contributions are welcome!"
    ),
}

# -- Options for HTML output -----------------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output

html_theme = "furo"
html_static_path = ["_static"]

# -- Options for autodoc extension  ----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#configuration

autodoc_default_options = {
    "members": None,
}

# -- Options for autodoc_pydantic extension  -------------------------------------------
# https://autodoc-pydantic.readthedocs.io/en/stable/users/configuration.html

autodoc_pydantic_settings_show_json = False

# -- Options for myst-parser extension  ------------------------------------------------
# https://myst-parser.readthedocs.io/en/latest/configuration.html

myst_enable_extensions = [
    "colon_fence",
    "deflist",
]
myst_heading_anchors = 3
myst_url_schemes = {
    "http": None,
    "https": None,
    "vscode": None,
}


================================================
FILE: docs/development/cleanup-dev-env.md
================================================
# Clean Up Development Environment

When encountering environment-related problems, a straightforward solution is to cleanup the environment and setup a new one. Three different levels of cleanup approach are provided here.

## Intermediate cleanup

Intermediate cleanup only removes common intermediate files, such as generated documentation, package, coverage report, cache files for mypy, pytest, ruff and so on.

```bash
make clean
```

## Deep cleanup

Deep cleanup removes the pre-commit hook and the virtual environment alongside the common intermediate files.

```bash
make deepclean
```

## Complete cleanup

Complete cleanup restores the repository to its original, freshly-cloned state, ideal for starting over from scratch.

```{caution}
This will remove all untracked files, please use it with caution. It is recommended to check with dry-run mode (`git clean -dfnx`) before actually removing anything. For more information, please refer to the [git-clean documentation](https://git-scm.com/docs/git-clean).
```

```bash
git clean -dfx
```


================================================
FILE: docs/development/commit.md
================================================
# Commit Convention

Using structured commit messages, we can enhance the readability of our project history, simplify automated changelog generation, and streamline the release process. We primarily follow the [Conventional Commit](https://www.conventionalcommits.org/) and [Angular's commit guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits).

## Commit Message Pattern

```text
<type>(<optional scope>): <description>
```

Examples:

```text
build(dependencies): bump the prod group with 9 updates.
doc: Add doc for commit convention.
chore: remove deprecated key in ruff config.
```

Type
: Describes the nature of the change:

| Type      | Description                                            |
|-----------|--------------------------------------------------------|
| `build`   | Changes that affect the build system or dependencies.  |
| `chore`   | Routine tasks or changes outside the src/runtime code. |
| `ci`      | Changes related to continuous integration.             |
| `doc`     | Documentation changes.                                 |
| `feat`    | New features.                                          |
| `fix`     | Bug fixes.                                             |
| `perf`    | Performance improvements.                              |
| `refactor`| Code restructuring without changing behavior.          |
| `revert`  | Revert a previous commit.                              |
| `style`   | Code formatting changes.                               |
| `test`    | Add or update tests.                                   |

Scope [Optional]
: Represents the part of the project impacted by the change. Examples include `logging`, `settings`, and `cli`.

### Breaking Change

A "breaking change" refers to any modification that disrupts the existing functionality in a way that may affect users. It can be denoted using an exclamation mark (`!`) before the colon, like `refactor!: Stuff`.

## Commit in Development Branches

While the commit convention seems strict, we aim for flexibility during the development phase.
By adhering to the <project:/management/settings.md>, all changes should be introduced via pull/merge requests.
Using the squash merge strategy, the emphasis is primarily on the title of pull/merge requests.
In this way, individual commit within development branches does not need to strictly adhere to the commit convention.

````{note}
A CI/CD pipeline checks the titles of pull/merge requests against the following regex pattern:

```text
^(build|chore|ci|doc|feat|fix|perf|refactor|revert|style|test)(\(\w+\))?!?:\s.*
```
````


================================================
FILE: docs/development/git-workflow.md
================================================
# Git Workflow

This pages shows the recommended Git workflow to keep the local repository clean and organized while ensuring smooth collaboration among team members.

## Prerequisites

Make sure you have [Git](https://git-scm.com/) (version 2.23 and above) installed and properly configured especially for authentication.

## Fork and clone the repository

Fork the repository to your own namespace, and let us take `https://github.com/<username>/iptvtools` as example.

Clone the repository and navigate to the root directory:

```shell
git clone git@github.com:<username>/iptvtools.git
cd iptvtools
```

## Configure the remote

Add and update the `upstream` remote repository:

```shell
git remote add upstream https://github.com/huxuan/iptvtools
git fetch upstream
```

Configure `git` to pull `main` branch from the `upstream` remote:

```shell
git config --local branch.main.remote upstream
```

Configure `git` never to push to the `upstream` remote:

```shell
git remote set-url --push upstream git@github.com/<username>/iptvtools.git
```

## Verify the remote configuration

List the remote repositories with urls:

```shell
git remote -v
```

You should have two remote repositories: `origin` to your forked CPython repository, and `upstream` pointing to the official CPython repository:

```shell
origin  git@github.com:<username>/iptvtools.git (fetch)
origin  git@github.com:<username>/iptvtools.git (push)
upstream        https://github.com/huxuan/iptvtools (fetch)
upstream        git@github.com:<username>/iptvtools.git (push)
```

Note that the push url of `upstream` repository is the forked repository.

Show the upstream for `main` branch:

```shell
git config branch.main.remote
```

You should see `upstream` here.

## Work on a feature branch

Create and switch to a new branch from `main`:

```shell
git switch -c <branch-name> main
```

Stage the changed files:

```shell
git add -p # to review and add changes to existing files
git add <filename1> <filename2> # to add new files
```

Commit the staged files:

```shell
git commit -m "the commit message"
```

Push the committed changes:

```shell
git push
```

## Create a pull request

Navigate to the hosting platform and create a pull request.

After the pull request is merged, you need to delete the branch in your namespace.

```{note}
It is recommended to configure the automatic deletion of the merged branches.
```

## Housekeeping the cloned repository

Update the `main` branch from upstream:

```shell
git switch main
git pull upstream main
```

Remove deleted remote-tracking references:

```shell
git fetch --prune origin
```

Remove local branches:

```shell
git branch -D <branch-name>
```

After all these operations, you should be ready to <project:#work-on-a-feature-branch> again.

## Reference

- [Git bootcamp and cheat sheet, Python Developer's Guide](https://devguide.python.org/getting-started/git-boot-camp/)


================================================
FILE: docs/development/index.md
================================================
# Development Practices

This section is designed for developers and covers essential topics during daily development lifecycle. Follow these guidelines to ensure all contributors adhere to best practices, maintain code quality, and collaborate efficiently.

```{toctree}
git-workflow
setup-dev-env
cleanup-dev-env
commit
tests
```


================================================
FILE: docs/development/setup-dev-env.md
================================================
# Set Up Development Environment

This page shows the approach to set up development environment. To simplify the process, a unified `Makefile` is maintained at the root directory of the repo. In other words, all the `make` related commands are supposed to run there.

## Prerequisites

[pipx](https://pipx.pypa.io/) is required to manage the standalone tools used across the development lifecycle.
Please refer to pipx's installation instructions [here](https://pipx.pypa.io/stable/installation/).
Once pipx is set up, install the needed standalone tools with the following command:

```bash
make prerequisites
```

## Setup

Development environment can be setup with the following command:

```bash
make dev
```

This command will accomplish the following tasks:

- Create a virtual environment.
- Install all the dependencies, including those for documentation, lint, package and test.
- Install the project in editable mode.
- Install git hook scripts for `pre-commit`.

To speed up the setup process in certain scenarios, you may find <project:/advanced/partial-dev-env.md> helpful.


================================================
FILE: docs/development/tests.md
================================================
# Tests

In the context of CI/CD automation, dependency updates, and the release process, tests play a crucial role in daily development. We utilize [pytest](https://docs.pytest.org/) and [coverage](https://coverage.readthedocs.io) with proper configuration to ensure everything works as expected. This page provides general information and conventions we wish you to follow.

## Running Tests

After [setting up the development environment](/development/setup-dev-env.md), tests can be run with the command:

```bash
make test
```

With the default configuration, this command displays the result for each test case, the execution time for slow test cases, and a report on test coverage.

## Writing Tests

For guidelines on how to write tests, refer to [the official documentation](https://docs.pytest.org/how-to/assert.html). Here are some conventions we expect you to follow:

1. Organize all test cases under the `tests` directory.
2. Align test modules with the modules to be tested.

   For example, tests for the `iptvtools.cli` module should be located in the file `tests/cli_test.py`. If there are too many test cases, they can be split into files within the `tests/cli/` directory, using a prefix for each test file.
3. Unless necessary, do not lower the threshold of the test coverage.

## Coverage Report

After running the tests, the coverage report will be printed on the screen and generated as part of the documentation. You can view it [here](/reports/coverage/index.md).


================================================
FILE: docs/index.md
================================================
# Welcome to IPTVTools's documentation

```{toctree}
:hidden:
Overview <self>
usage/index
management/index
development/index
advanced/index
cli/index
api/index
reports/index
Changelog <https://github.com/huxuan/iptvtools/releases>
```

```{include} ../README.md
:start-line: 1
```

## 🔖 Indices and tables

* {ref}`genindex`
* {ref}`modindex`
* {ref}`search`


================================================
FILE: docs/management/index.md
================================================
# Project Management

This section is designed for project maintainers and covers essential tasks for managing your project. Follow these guidelines to ensure your project remains up-to-date and adheres to best practices.

```{toctree}
init
settings
update
release
```


================================================
FILE: docs/management/init.md
================================================
# Project Initialization

## Prerequisites

[pipx](https://pipx.pypa.io/) is required to manage the standalone tools used across the development lifecycle.
Please refer to pipx's installation instructions [here](https://pipx.pypa.io/stable/installation/).
Once pipx is set up, install the copier for project generation using the following command:

```bash
pipx install copier==9.4.1
```

## Create the Repository

Create a blank Git repository on the hosting platform. Clone it locally and navigate to the root directory:

```bash
git clone git@github.com:huxuan/iptvtools.git
cd iptvtools
```

## Generate the Project

Running the following command and answer the prompts to set up the project:

```bash
copier copy gh:serious-scaffold/ss-python .
```

## Set Up Development Environment

Set up development environment to prepare for the initial commit:

```bash
make dev
```

## Commit and push

```bash
git add .
git commit -m "chore: init from serious-scaffold-python"
SKIP=no-commit-to-branch git push
```

Now, everything is done!


================================================
FILE: docs/management/release.md
================================================
# Release Process

With the integration of [semantic-release](https://github.com/semantic-release/semantic-release), the release process is fully automated. To enable this, follow the settings for <project:/management/settings.md#renovate-and-semantic-release>. Besides, adhering to the <project:/development/commit.md#commit-message-pattern> is strongly recommended to ensure the release process works as expected.

## Release Configuration

The release configuration is located in the root directory of the project:

```{literalinclude} ../../.releaserc.json
```

Based on this configuration, the following trigger rules apply:

* A **major** release is triggered by a 'BREAKING CHANGE' or 'BREAKING-CHANGE' in the footer or has a `major-release` scope.
* A **minor** release is triggered when the commit type is `feat` or has a `minor-release` scope.
* A **patch** release is triggered when the commit type is `fix`, `perf`, `refactor` or `revert` or has a `patch-release` scope.
* No release is triggered if the commit type is any other type or has a `no-release` scope.

## Commit message examples

### Major release

* ```text
  feat: drop Python 3.8 support

  BREAKING CHANGE: drop Python 3.8 support
  ```
* `chore(major-release): a major release`

### Minor release

* `feat: add an awesome feature`
* `chore(minor-release): a minor release`

### Patch release

* `fix: fix a silly bug`
* `perf: performance improvement for the core`
* `refactor: refactor the base module`
* `revert: revert a buggy implementation`
* `chore(patch-release): a patch release`

### No release

* `feat(no-release): a feature that should not trigger a release`
* `fix(no-release,core): a fix that should not trigger a release, but with more scopes`

## Release Tasks

The release process includes the following tasks:

::::{tab-set}

:::{tab-item} GitHub
:sync: github

1. Generate a changelog from unreleased commits.
1. Publish a new GitHub Release and semantic version tag.
1. Build and publish the documentation to GitHub Pages.
1. Build and publish the Python package to the configured package repository.
1. Build and publish the Development and Production Containers with the build cache to GitHub Packages.
    1. The Production Container is tagged as `ghcr.io/huxuan/iptvtools:py<PYTHON_VERSION>` for the latest version and `ghcr.io/huxuan/iptvtools:py<PYTHON_VERSION>-<PROJECT_VERSION>` for archives.
    1. The Development Container is tagged as `ghcr.io/huxuan/iptvtools/dev:py<PYTHON_VERSION>` for the latest version and `ghcr.io/huxuan/iptvtools/dev:py<PYTHON_VERSION>-<PROJECT_VERSION>` for archives.
    1. The build cache for the Development Container is tagged as `ghcr.io/huxuan/iptvtools/dev-cache:py<PYTHON_VERSION>`.

:::

:::{tab-item} GitLab
:sync: gitlab

1. Generate a changelog from unreleased commits.
1. Publish a new GitLab Release and semantic version tag.
1. Build and publish the documentation to GitLab Pages.
1. Build and publish the Python package to the configured package repository.
1. Build and publish the Development and Production Containers with build cache to GitLab Container Registry.
    1. The Production Container is tagged as `registry.gitlab.com/huxuan/iptvtools:py<PYTHON_VERSION>` for the latest version and `registry.gitlab.com/huxuan/iptvtools:py<PYTHON_VERSION>-<PROJECT_VERSION>` for archives.
    1. The Development Container is tagged as `registry.gitlab.com/huxuan/iptvtools/dev:py<PYTHON_VERSION>` for the latest version and `registry.gitlab.com/huxuan/iptvtools/dev:py<PYTHON_VERSION>-<PROJECT_VERSION>` for archives.
    1. The build cache for the Development Container is tagged as `registry.gitlab.com/huxuan/iptvtools/dev-cache:py<PYTHON_VERSION>`.

:::

::::


================================================
FILE: docs/management/settings.md
================================================
# Repository Settings

There are several settings to utilize the features provided by the project template. Although some of them are not strictly required, it is highly recommended finish these one-time jobs so as to benefit on the whole development lifecycle.

## Branch protection

::::{tab-set}

:::{tab-item} GitHub
:sync: github

1. Navigate to the [Branch protection rules](https://github.com/huxuan/iptvtools/settings/branches) settings.
1. Ensure a rule for the default `main` branch.
1. Enable **Require a pull request before merging** with **Require approvals** and **Dismiss stale pull request approvals when new commits are pushed** enabled.
1. Enable **Require status checks to pass before merging** and set [ci](https://github.com/huxuan/iptvtools/actions/workflows/ci.yml) and [commitlint](https://github.com/huxuan/iptvtools/actions/workflows/commitlint.yml) as required status checks.

:::

:::{tab-item} GitLab
:sync: gitlab

1. Navigate to the [Repository](https://gitlab.com/huxuan/iptvtools/-/settings/repository) settings and the **Protected branches** section.
1. Ensure the default `main` branch is protected with **Maintainers** for **Allowed to merge**, **No one** for **Allowed to push and merge** and **Allowed to force push** disabled.

:::
::::

## Tag protection

::::{tab-set}

:::{tab-item} GitHub
:sync: github

1. Navigate to the [Protected tags](https://github.com/huxuan/iptvtools/settings/tag_protection) settings.
1. Create a rule for tag name pattern `v*`.

:::

:::{tab-item} GitLab
:sync: gitlab

1. Navigate to the [Repository](https://gitlab.com/huxuan/iptvtools/-/settings/repository) settings and the **Protected tags** section.
1. Add a rule with wildcard `v*` for **Tag** and **Maintainers** for **Allowed to create**.

:::
::::

## Squash merge

::::{tab-set}

:::{tab-item} GitHub
:sync: github

1. Navigate to the [General](https://github.com/huxuan/iptvtools/settings) settings and the **Pull Requests** section.
1. Disable **Allow merge commits** and **Allow rebase merging**.
1. Enable **Allow squash merging** and set **Pull request title** as **Default commit message**.

:::

:::{tab-item} GitLab
:sync: gitlab

1. Navigate to the [Merge requests](https://gitlab.com/huxuan/iptvtools/-/settings/merge_requests) settings.
1. Set **Fast-forward merge** for the **Merge method**.
1. Set **Require** for the **Squash commits when merging**.
1. Enable **Pipelines must succeed** in the **Merge checks**.

:::
::::

## Pages

::::{tab-set}

:::{tab-item} GitHub
:sync: github

1. Navigate to the [GitHub Pages](https://github.com/huxuan/iptvtools/settings/pages) settings.
1. Set **GitHub Actions** as **Source**.

:::

:::{tab-item} GitLab
:sync: gitlab

Nothing need to do for GitLab Pages.

:::
::::

## Package publish

::::{tab-set}

:::{tab-item} GitHub
:sync: github

1. Navigate to the [Actions secrets and variables](https://github.com/huxuan/iptvtools/settings/secrets/actions) settings.
1. Set the **variable** `PDM_PUBLISH_REPO`, the repository (package index) URL to upload the package which defaults to `https://pypi.org`, the official PyPI.
1. Set the **variable** `PDM_PUBLISH_USERNAME`, the username to authenticate to the repository (package index) which defaults to `__token__`, used for [API token](https://pypi.org/help/#apitoken).
1. Set the **secret** `PDM_PUBLISH_PASSWORD`, the password to authenticate to the repository (package index).

:::

:::{tab-item} GitLab
:sync: gitlab

1. Navigate to the [CI/CD](https://gitlab.com/huxuan/iptvtools/-/settings/ci_cd) settings and the **Variables** section.
1. Set the variable `PDM_PUBLISH_REPO`, the repository (package index) URL to upload the package, default to `https://pypi.org`, the official PyPI.
1. Set the variable `PDM_PUBLISH_USERNAME`, the username to authenticate to the repository (package index), default to `__token__`, used for [API token](https://pypi.org/help/#apitoken).
1. Set the variable `PDM_PUBLISH_PASSWORD` with the **Mask variable** option for security, the password to authenticate to the repository (package index).

:::
::::

## Renovate and semantic-release

::::::{tab-set}

:::::{tab-item} GitHub
:sync: github

There are two approaches, either with GitHub App or with personal access token (classic). GitHub App is the more recommended way to avoid the issues and pull requests tied to a particular user.

::::{tab-set}

:::{tab-item} GitHub App

  1. [Register a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) with permission listed [here](https://docs.renovatebot.com/modules/platform/github/#running-as-a-github-app) and `Repository administration: write` permission as mentioned [here](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/configuring-tag-protection-rules#about-tag-protection-rules).
  1. [Generate a private key](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps#generating-private-keys), and download the private key as a `.pem` file.
  1. Navigate to the [Actions secrets and variables](https://github.com/huxuan/iptvtools/settings/secrets/actions) settings.
  1. Set **App ID** of the GitHub App as **variable** `BOT_APP_ID`.
  1. Set the content of the private key as **secret** `BOT_PRIVATE_KEY`.

:::

:::{tab-item} personal access token (classic)

1. [Create a personal access token (classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) with **workflow** scope.
1. Navigate to the [Actions secrets and variables](https://github.com/huxuan/iptvtools/settings/secrets/actions) settings and set the token as a **secret** `PAT`.

:::
::::

```{note}
You can set the scope of the variables and secrets to **Repository** or **Organization** according to actual requirements.
```

:::::

:::::{tab-item} GitLab
:sync: gitlab

Either [Group access tokens](https://docs.gitlab.com/ee/user/group/settings/group_access_tokens.html), [Project access tokens](https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html) or [Personal access tokens](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) can be used. The group or project access tokens are more recommended to avoid the issues and merge requests tied to particular user.

1. Create a [group access token](https://gitlab.com/groups/huxuan/-/settings/access_tokens), [project access token](https://gitlab.com/huxuan/iptvtools/-/settings/access_tokens) or [personal access token](https://gitlab.com/-/user_settings/personal_access_tokens) with `Maintainer` role and `api, write_repository` scope.
1. Navigate to the [CI/CD](https://gitlab.com/huxuan/iptvtools/-/settings/ci_cd) settings and the **Variables** section. Set the token as variable `PAT` with the **Mask variable** option for security.
1. Navigate to the [Pipeline schedules](https://gitlab.com/huxuan/iptvtools/-/pipeline_schedules). Create a new schedule with `*/15 0-3 * * 1` as **Interval Pattern** and mark it as **Activated**.

```{note}
Although optional, [creating a personal access token (classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) on **GitHub** is strongly recommended. This token only needs `read-only` access and will increase the rate limit for Renovate to fetch dependencies and changelogs from github.com. It can be from any account and should be set as the variable `GITHUB_COM_TOKEN` with the **Mask variable** option for security. For more information on setting this up, see [Renovate's documentation](https://docs.renovatebot.com/getting-started/running/#githubcom-token-for-changelogs).
```

:::::
::::::


================================================
FILE: docs/management/update.md
================================================
# Template and Dependency Update

## Template update

To update the project template, thanks to the [update feature](https://copier.readthedocs.io/en/stable/updating/) provided by [Copier](https://github.com/copier-org/copier) and the [regex manager](https://docs.renovatebot.com/modules/manager/regex/) provided by Renovate, a pull request will be automatically created when a new version of the template is released. In most cases, Copier will update the project seamlessly. If conflicts arise, they can be resolved manually since everything is version-controlled by Git.

### Tips to minimize potential conflicts

To minimize potential conflicts, consider the following suggestions:

1. Avoid modifying the auto-generated files unless necessary.
1. For template-related changes, consider proposing an issue or a pull request to the [project template repository](http://github.com/serious-scaffold/ss-python) directly.
1. For project-specific changes, adopt an inheritance or extension approach to minimize modifications to auto-generated content.

## Dependency update

With the integration of [Renovate](https://github.com/renovatebot/renovate), all dependencies, including those used for development and CI/CD, will be automatically updated via pull requests whenever a new version is released. This allows us to focus solely on testing to ensure the new versions do not break anything. Moreover, an issue titled "Dependency Dashboard" will be created, so that you can have an overview of the state of all dependencies.

### Managed dependency types

The project template tracks the following dependencies:

1. Supported managers other than `regex`:
   1. [pep621](https://docs.renovatebot.com/modules/manager/pep621/): The lock file generated by PDM for both dependencies and development dependencies in `pyproject.toml`.
   1. [github-actions](https://docs.renovatebot.com/modules/manager/github-actions/): Actions, runners and containers in GitHub Actions.
   1. [gitlabci](https://docs.renovatebot.com/modules/manager/gitlabci/): Containers in GitLab CI/CD.
   1. [pre-commit](https://docs.renovatebot.com/modules/manager/pre-commit/): Pre-commit hooks.
1. Regex manager:
   1. Python packages installed with pip/pipx, listed in the README, DevContainer Dockerfile, GitHub Actions, GitLab CI/CD, ReadTheDocs configuration, Renovate configuration and documentation.
   1. Debian packages installed in the DevContainer Dockerfile.
   1. PDM version specified in the `pdm-project/setup-pdm` GitHub action.
   1. PDM version specified in the renovate constraints.
   1. NPM packages used with npx.
   1. The project template itself.

### Add new dependencies

When adding new dependencies that belong to the managed dependency type mentioned above, it is recommended to pin or lock their versions to ensure they are smoothly managed by Renovate.

When adding new types of dependencies, it is also recommended to manage them with Renovate.

- If this follows a common pattern, consider creating an issue or even sending a pull request to project template directly.
- If it is project-specific, you can extend the renovate configuration:
  - For supported managers other than `regex`, add them in the Renovate configuration using environment variable `RENOVATE_ENABLED_MANAGERS` in GitHub Actions or GitLab CI/CD and configure them in the `renovaterc.json` under the root directory if needed.
  - For `regex` managers, add new entries in the `customManagers` and configure `packageRules` if needed in the `.renovaterc.json`.

  ```{note}
  This also adheres to the <project:#tips-to-minimize-potential-conflicts>.
  ```

```{note}
For the complete list of supported managers and their corresponding configurations, please refer to the [Managers - Renovate Docs](https://docs.renovatebot.com/modules/manager/).
```


================================================
FILE: docs/reports/coverage/index.md
================================================
# Coverage Reports

<!-- placeholder for generated coverage reports -->


================================================
FILE: docs/reports/index.md
================================================
# Code Quality Reports

```{toctree}
:maxdepth: 2
mypy/index
coverage/index
```


================================================
FILE: docs/reports/mypy/index.md
================================================
# MyPy Reports

<!-- placeholder for generated mypy reports -->


================================================
FILE: docs/usage/filter.md
================================================
# Filter

## Reference

<project:/cli/filter.md>

## Example

There is a [well-maintained IPTV list](https://gist.github.com/sdhzdmzzl/93cf74947770066743fff7c7f4fc5820) only for Beijing Unicom and a [well-maintained templates & EPG](http://epg.51zmt.top:8000/) mainly for China. So for me::

```bash
$ iptvtools-cli filter \
-i https://gist.githubusercontent.com/sdhzdmzzl/93cf74947770066743fff7c7f4fc5820/raw/11107d2dcfe2f5785e7ada94bb44c0cd349191c5/bj-unicom-iptv.m3u \
-t http://epg.51zmt.top:8000/test.m3u
```

With UDPXY, it becomes::

```bash
$ iptvtools-cli filter \
-i https://gist.githubusercontent.com/sdhzdmzzl/93cf74947770066743fff7c7f4fc5820/raw/11107d2dcfe2f5785e7ada94bb44c0cd349191c5/bj-unicom-iptv.m3u \
-t http://epg.51zmt.top:8000/test.m3u \
-u http://192.168.0.1:8888
```

Just replace `http://192.168.0.1:8888` with corresponding UDPXY prefix should be OK.

## Selected Parameters

Here is some further explanation for those not so obvious parameters.

### GROUP_EXCLUDE

Filter the playlist depends on the group title with a blacklist (regular expression).
Note that, it has higher priority than the whitelist ``GROUP_INCLUDE``.

### GROUP_INCLUDE

Filter the playlist depends on the group title with a whitelist (regular expression).
Note that, if set, only groups match the pattern will be included.

### CHANNEL_EXCLUDE

Filter the playlist depends on the channel title by a blacklist (regular expression).
Note that, it has higher priority than the whitelist ``CHANNEL_INCLUDE``.

### CHANNEL_INCLUDE

Filter the playlist depends on the channel title by a whitelist (regular expression).
Note that, if set, only channels match the pattern will be included.

### MIN_HEIGHT

HEIGHT is a dominant factor of stream quality,
where 1080 in height means 1080p.
It is necessary to set this filter
if the stream is supposed to be shown on high resolution screens,
e.g., a 4K TV.

### CONFIG

[CONFIG](https://github.com/huxuan/iptvtools/blob/master/config.json)
is a customized configuration to unify ``title`` and ``id``.
``title`` is the exact title which will be shown and
the ``id`` is used for potential match with the template.
A general idea is to make the ``id`` as simple as possible
so they will have a high possibility to match,
though there might be some false positive cases.
So, ``id_unifiers`` can be treated as
a further simplification of ``title_unifier``.

For example, entry ``"-": ""`` will convert ``CCTV-1`` to ``CCTV1``,
entry ``"+": "+"`` will convert ``CCTV-5+`` to ``CCTV-5+``.
A whole replacement is also possible,
as ``"BTV冬奥纪实": "北京纪实"`` will
match the whole of ``BTV冬奥纪实`` and
replace it with ``北京纪实``.

Please be caution about using too many of them
since this simplified strategy is just for some basic requirement.
Some entries may lead to some unexpected changes.
For example, entry ``"CCTV-1": "中央1套"`` will convert ``CCTV-11`` to ``中央1套1``.
So, in generally,
only keep those necessary entries and keep it as simple as possible.

### SORT_KEYS

List of keys to sort the channels. Valid options currently supported are
`tvg-id`, `height` and `title`. By default, it will work the same as
`-s tvg-id resolution title`, and you can change the order as you want.
If you want to have more keys to be supported, just let me know.

### TEMPLATES

A m3u playlist with well-maintained information to cooperate with EPG.
Please refer to [Well‐maintained templates & EPGs](https://github.com/huxuan/iptvtools/wiki/Well%E2%80%90maintained-templates-&-EPGs).

BTW, there is also a list [Well‐maintained playlists](https://github.com/huxuan/iptvtools/wiki/Well%E2%80%90maintained-playlists).

### TIMEOUT

TIMEOUT is used to check the connectivity.
Direct check which only fetch the response header tends to be fast.
But it usually takes seconds to probe stream information
depends on your network (bandwidth and latency).
For me, it is about 3 to 5 seconds.

### UDPXY

If the IPTV streams is forwarded by UDPXY,
setting it will convert all the urls automatically.
For examples, with UDPXY `http://192.168.0.1:8888/`,
`rtp://123.45.67.89:1234` will be converted to
`http://192.168.0.1:8888/rtp/123.45.67.89:1234`.

### SKIP_CONNECTIVITY_CHECK

Skip any connectivity check (to be used to just apply title and id unifiers)
use in combination with `-I 0`


================================================
FILE: docs/usage/index.md
================================================
# Usage

```{toctree}
filter
```


================================================
FILE: pyproject.toml
================================================
[build-system]
build-backend = "setuptools.build_meta"
requires = [
    "setuptools==75.8.0",
    "setuptools-scm==8.2.0",
]

[project]
name = "iptvtools"
description = "A set of scripts that help to better IPTV experience."
readme = "README.md"
keywords = [
    "iptvtools",
    "iptvtools-cli",
    "m3u filter",
    "serious-scaffold",
]
license = { text = "MIT" }
authors = [
    { email = "i@huxuan.org", name = "huxuan" },
]
requires-python = ">=3.10"
classifiers = [
    "Development Status :: 4 - Beta",
    "License :: OSI Approved :: MIT License",
    "Operating System :: MacOS :: MacOS X",
    "Operating System :: POSIX :: Linux",
    "Programming Language :: Python :: 3 :: Only",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
]
dynamic = [
    "version",
]
dependencies = [
    "click>=8.1.8",
    "pydantic-settings>=2.7.1",
    "requests>=2.32.3",
    "tqdm>=4.67.1",
]
urls.documentation = "https://huxuan.github.io/iptvtools"
urls.issue = "https://github.com/huxuan/iptvtools/issues"
urls.repository = "https://github.com/huxuan/iptvtools"
scripts.iptvtools-cli = "iptvtools.cli:cli"

[dependency-groups]
test = [
    "coverage>=7.6.10",
    "pytest>=8.3.4",
]
doc = [
    "autodoc-pydantic>=2.2.0",
    "coverage>=7.6.10",
    "furo>=2024.8.6",
    "mypy[reports]>=1.14.1",
    "myst-parser>=4.0.0",
    "pytest>=8.3.4",
    "sphinx>=8.1.3",
    "sphinx-click>=6.0.0",
    "sphinx-design>=0.6.1",
    "types-requests>=2.32.0.20241016",
    "types-tqdm>=4.67.0.20241221",
]
lint = [
    "mypy>=1.14.1",
    "types-requests>=2.32.0.20241016",
    "types-tqdm>=4.67.0.20241221",
]

[tool.pdm]
distribution = true

[tool.setuptools_scm]
fallback_version = "0.0.0"

[tool.ruff]
src = [
    "src",
]
fix = true
lint.select = [
    "B",      # flake8-bugbear
    "D",      # pydocstyle
    "E",      # pycodestyle error
    "F",      # Pyflakes
    "I",      # isort
    "RUF100", # Unused noqa directive
    "S",      # flake8-bandit
    "SIM",    # flake8-simplify
    "UP",     # pyupgrade
    "W",      # pycodestyle warning
]
lint.per-file-ignores."tests/*" = [
    "S101",
]
lint.pydocstyle.convention = "google"

[tool.codespell]
write-changes = true
check-filenames = true

[tool.pyproject-fmt]
indent = 4
keep_full_version = true
max_supported_python = "3.13"

[tool.pytest.ini_options]
addopts = "-l -s --durations=0"
log_cli = true
log_cli_level = "info"
log_date_format = "%Y-%m-%d %H:%M:%S"
log_format = "%(asctime)s %(levelname)s %(message)s"
minversion = "6.0"

[tool.coverage.report]
fail_under = 0

[tool.coverage.run]
source = [
    "iptvtools",
]

[tool.mypy]
check_untyped_defs = true
disallow_any_unimported = true
disallow_untyped_defs = true
enable_error_code = [
    "ignore-without-code",
]
exclude = [
    "build",
    "doc",
]
no_implicit_optional = true
show_error_codes = true
warn_return_any = true
warn_unused_ignores = true


================================================
FILE: scripts/generate-coverage-badge.sh
================================================
#!/bin/bash

TOTAL_COVERAGE=$(coverage report --format=total)
COLOR="#9f9f9f"

if [ "$TOTAL_COVERAGE" -gt 95 ]; then
    COLOR="#4c1"
elif [ "$TOTAL_COVERAGE" -gt 90 ]; then
    COLOR="#a3c51c"
elif [ "$TOTAL_COVERAGE" -gt 75 ]; then
    COLOR="#dfb317"
elif [ "$TOTAL_COVERAGE" -gt 0 ]; then
    COLOR="#e05d44"
fi

COVERAGE_JSON_DIR=${1:-.}
mkdir -p "$COVERAGE_JSON_DIR"

cat << EOF > "${COVERAGE_JSON_DIR}/coverage.json"
{
  "schemaVersion": 1,
  "label": "coverage",
  "message": "${TOTAL_COVERAGE}%",
  "color": "${COLOR}"
}
EOF


================================================
FILE: src/iptvtools/__init__.py
================================================
"""Init for the project."""


================================================
FILE: src/iptvtools/cli.py
================================================
"""Command Line Interface."""

import logging
import shutil

import click

from iptvtools import exceptions
from iptvtools.config import Config
from iptvtools.constants import defaults, helps
from iptvtools.models import Playlist


@click.group(
    context_settings={"show_default": True},
)
@click.version_option()
def cli() -> None:
    """CLI for IPTVTools."""


@cli.command()
@click.option("--channel-exclude", help=helps.CHANNEL_EXCLUDE)
@click.option("--channel-include", help=helps.CHANNEL_INCLUDE)
@click.option("--group-exclude", help=helps.GROUP_EXCLUDE)
@click.option("--group-include", help=helps.GROUP_INCLUDE)
@click.option(
    "--max-height", default=defaults.MAX_HEIGHT, type=int, help=helps.MAX_HEIGHT
)
@click.option(
    "--min-height", default=defaults.MIN_HEIGHT, type=int, help=helps.MIN_HEIGHT
)
@click.option("-c", "--config", default=defaults.CONFIG, help=helps.CONFIG)
@click.option(
    "-i", "--inputs", multiple=True, default=defaults.INPUTS, help=helps.INPUTS
)
@click.option(
    "-I", "--interval", default=defaults.INTERVAL, type=int, help=helps.INTERVAL
)
@click.option("-L", "--log-level", default=defaults.LOG_LEVEL, help=helps.LOG_LEVEL)
@click.option(
    "-n",
    "--skip-connectivity-check",
    is_flag=True,
    help=helps.SKIP_CONNECTIVITY_CHECK,
)
@click.option("-o", "--output", default=defaults.OUTPUT, help=helps.OUTPUT)
@click.option(
    "-r",
    "--replace-group-by-source",
    is_flag=True,
    help=helps.REPLACE_GROUP_BY_SOURCE,
)
@click.option(
    "-R",
    "--resolution-on-title",
    is_flag=True,
    help=helps.RESOLUTION_ON_TITLE,
)
@click.option(
    "-s", "--sort-keys", multiple=True, default=defaults.SORT_KEYS, help=helps.SORT_KEYS
)
@click.option(
    "-t", "--templates", multiple=True, default=defaults.TEMPLATES, help=helps.TEMPLATES
)
@click.option("-T", "--timeout", default=defaults.TIMEOUT, type=int, help=helps.TIMEOUT)
@click.option("-u", "--udpxy", default=defaults.UDPXY, help=helps.UDPXY)
def filter(
    channel_exclude: str,
    channel_include: str,
    group_exclude: str,
    group_include: str,
    max_height: int,
    min_height: int,
    config: str,
    inputs: list[str],
    interval: int,
    log_level: str,
    skip_connectivity_check: bool,
    output: str,
    replace_group_by_source: bool,
    resolution_on_title: bool,
    sort_keys: list[str],
    templates: list[str],
    timeout: int,
    udpxy: str,
) -> None:
    """Filter m3u playlists."""
    logging.basicConfig(level=log_level.upper())

    if (max_height or min_height or resolution_on_title) and shutil.which(
        "ffprobe"
    ) is None:
        raise exceptions.FFmpegNotInstalledError()

    Config.init(config)
    playlist = Playlist(
        channel_exclude,
        channel_include,
        group_exclude,
        group_include,
        max_height,
        min_height,
        inputs,
        interval,
        skip_connectivity_check,
        output,
        replace_group_by_source,
        resolution_on_title,
        sort_keys,
        templates,
        timeout,
        udpxy,
    )
    playlist.parse()
    playlist.filter()
    playlist.export()
    if playlist.inaccessible_urls:
        logging.info("Inaccessible Urls:")
        logging.info("\n".join(sorted(playlist.inaccessible_urls)))
    if playlist.low_res_urls:
        logging.info("Low resolution Urls:")
        logging.info("\n".join(sorted(playlist.low_res_urls)))
    if playlist.high_res_urls:
        logging.info("High resolution Urls:")
        logging.info("\n".join(sorted(playlist.high_res_urls)))


================================================
FILE: src/iptvtools/config.py
================================================
#!/usr/bin/env python
"""Configuration for iptvtools.

File: config.py
Author: huxuan
Email: i(at)huxuan.org
"""

import json
import os
import os.path
from pathlib import Path
from typing import Any


class MetaConfig(type):
    """Configuration for iptvtools."""

    config: dict[str, Any] = {}

    @classmethod
    def init(cls, config_file: str | Path) -> None:
        """Initialize configuration."""
        if os.path.isfile(config_file):
            with open(config_file) as fin:
                cls.config = json.load(fin)

    def __getattr__(cls, key: str) -> Any:
        """Get configuration with key."""
        return cls.config.get(key, {})


class Config(metaclass=MetaConfig):  # pylint: disable=R0903
    """Configuration for iptvtools."""


================================================
FILE: src/iptvtools/constants/__init__.py
================================================
#!/usr/bin/env python
"""Constants for iptvtools.

File: __init__.py
Author: huxuan
Email: i(at)huxuan.org
"""


================================================
FILE: src/iptvtools/constants/defaults.py
================================================
#!/usr/bin/env python
"""Defaults for iptvtools.

File: constants.py
Author: huxuan
Email: i(at)huxuan.org
"""

CONFIG = "config.json"
INPUTS = ["https://iptv-org.github.io/iptv/index.m3u"]
INTERVAL = 1
LOG_LEVEL = "INFO"
MAX_HEIGHT = -1
MIN_HEIGHT = 0
OUTPUT = "iptvtools.m3u"
SORT_KEYS = ["group-title", "tvg-id", "height", "title"]
TEMPLATES: list[str] = []
TIMEOUT = 10
UDPXY = None


================================================
FILE: src/iptvtools/constants/helps.py
================================================
#!/usr/bin/env python
"""Helps for iptvtools.

File: constants.py
Author: huxuan
Email: i(at)huxuan.org
"""

CONFIG = "Configuration file to unify title and id."
CHANNEL_EXCLUDE = (
    "Channels to exclude with regex. "
    "Note: Blacklist has higher priority than whitelist."
)
CHANNEL_INCLUDE = (
    "Channels to include with regex. "
    "Note: Only channels in the whitelist will be included if set."
)
GROUP_EXCLUDE = (
    "Groups to exclude with regex.Note: Blacklist has higher priority than whitelist."
)
GROUP_INCLUDE = (
    "Groups to include with regex."
    "Note: Only groups in the whitelist will be included if set."
)
INPUTS = "One or more input m3u playlist files/urls."
INTERVAL = "Interval in seconds between successive fetching requests."
LOG_LEVEL = "Log level."
MAX_HEIGHT = "Maximum height/resolution to accept, -1 means no resolution filtering."
MIN_HEIGHT = "Minimum height/resolution to accept, 0 means no resolution filtering."
OUTPUT = "Output file name."
REPLACE_GROUP_BY_SOURCE = (
    "Flag to replace the group title with the source name, where the source "
    "name is the basename of input files/urls without extension."
)
RESOLUTION_ON_TITLE = (
    "Flag to append resolution such as 8K, 4K, 1080p, 720p to the title."
)
SORT_KEYS = (
    "List of keys to sort the channels. Valid options currently supported "
    "are `group-title`, `tvg-id`, `template-order`, `height` and `title`."
)
TEMPLATES = (
    "Template m3u files/urls with well-maintained channel information to "
    "replace the matched entries."
)
TIMEOUT = "Timeout threshold for fetching request."
UDPXY = "UDP Proxy for certain IPTV channels."
SKIP_CONNECTIVITY_CHECK = "Skip connectivity check."


================================================
FILE: src/iptvtools/constants/patterns.py
================================================
#!/usr/bin/env python
"""Patterns for iptvtools.

File: constants.py
Author: huxuan
Email: i(at)huxuan.org
"""

import re

PARAMS = re.compile(r'(\S+)="(.*?)"')
EXTINF = re.compile(r"^#EXTINF:(?P<duration>-?\d+?) ?(?P<params>.*),(?P<title>.*?)$")
EXTM3U = re.compile(r"^#EXTM3U ?(?P<params>.*)$")


================================================
FILE: src/iptvtools/constants/tags.py
================================================
#!/usr/bin/env python
"""Tags for iptvtools.

File: constants.py
Author: huxuan
Email: i(at)huxuan.org
"""

M3U = "#EXTM3U"
INF = "#EXTINF"


================================================
FILE: src/iptvtools/exceptions.py
================================================
#!/usr/bin/env python
"""Custom exceptions for iptvtools.

File: exceptions.py
Author: huxuan
Email: i(at)huxuan.org
"""


class BaseCustomException(RuntimeError):
    """Base Custom Exception."""


class FFmpegNotInstalledError(BaseCustomException):
    """Raise when FFmpeg is not installed."""

    def __init__(self) -> None:
        """Init for FfmpegNotInstalledError."""
        super().__init__(
            "Need `FFmpeg` for resolution related processing.\n"
            "Please install it according to "
            "`https://www.ffmpeg.org/download.html`."
        )


================================================
FILE: src/iptvtools/models.py
================================================
#!/usr/bin/env python
"""Playlist which contains all the channels' information.

File: models.py
Author: huxuan
Email: i(at)huxuan.org
"""

import logging
import os.path
import random
import re
import sys
import time
from typing import Any

from tqdm import tqdm

from iptvtools import parsers, utils
from iptvtools.constants import defaults, tags


class Playlist:
    """Playlist model."""

    def __init__(
        self,
        channel_exclude: str,
        channel_include: str,
        group_exclude: str,
        group_include: str,
        max_height: int,
        min_height: int,
        inputs: list[str],
        interval: int,
        skip_connectivity_check: bool,
        output: str,
        replace_group_by_source: bool,
        resolution_on_title: bool,
        sort_keys: list[str],
        templates: list[str],
        timeout: int,
        udpxy: str,
    ) -> None:
        """Init for Playlist."""
        self.channel_exclude = channel_exclude
        self.channel_include = channel_include
        self.group_exclude = group_exclude
        self.group_include = group_include
        self.max_height = max_height
        self.min_height = min_height
        self.inputs = inputs
        self.interval = interval
        self.skip_connectivity_check = skip_connectivity_check
        self.output = output
        self.replace_group_by_source = replace_group_by_source
        self.resolution_on_title = resolution_on_title
        self.sort_keys = sort_keys
        self.templates = templates
        self.timeout = timeout
        self.udpxy = udpxy
        self.data: dict[str, Any] = {}
        self.id_url: dict[str, Any] = {}
        self.inaccessible_urls: set[str] = set()
        self.low_res_urls: set[str] = set()
        self.high_res_urls: set[str] = set()
        self.tvg_url = None

    def export(self) -> None:
        """Export playlist information."""
        res = []
        res.append(tags.M3U)
        if self.tvg_url is not None:
            res[0] += f' x-tvg-url="{self.tvg_url}"'
        for url in sorted(self.data, key=self.__custom_sort):
            if (
                url in self.inaccessible_urls
                or url in self.low_res_urls
                or url in self.high_res_urls
            ):
                continue

            entry = self.data[url]
            params_dict = entry.get("params", {})
            if self.replace_group_by_source:
                params_dict["group-title"] = self.data[url]["source"]
            params = " ".join(
                [f'{key}="{value}"' for key, value in params_dict.items()]
            )
            duration = entry["duration"]
            title = entry["title"]
            if self.resolution_on_title:
                height = self.data[url].get("height")
                title += f" [{utils.height_to_resolution(height)}]"

            res.append(f"{tags.INF}:{duration} {params},{title}\n{url}")

        with open(self.output, "w", encoding="utf-8") as f:
            f.write("\n".join(res))

    def parse(self) -> None:
        """Parse contents."""
        self._parse(self.inputs)
        logging.debug(self.data)
        self._parse(self.templates, is_template=True)
        logging.debug(self.data)

    def _parse(self, sources: list[str], is_template: bool = False) -> None:
        """Parse playlist sources."""
        template_order = 0
        for source in sources:
            source_name = os.path.splitext(os.path.basename(source))[0]
            current_item = {}
            skip = False
            is_first_line = True
            for line in parsers.parse_content_to_lines(source):
                if not line:
                    continue
                if is_first_line:
                    is_first_line = False
                    if line.startswith(tags.M3U):
                        res = parsers.parse_tag_m3u(line)
                        if res.get("tvg-url"):
                            self.tvg_url = res.get("tvg-url")
                        continue
                if skip:
                    skip = False
                    continue
                if line.startswith(tags.INF):
                    current_item = parsers.parse_tag_inf(line)
                    current_item = utils.unify_title_and_id(current_item)
                    current_id = current_item["id"]

                    params = current_item.get("params", {})
                    group = params.get("group-title", "")
                    if not skip and self.group_include:
                        if re.search(self.group_include, group):
                            logging.debug(f"Group to include: `{group}`.")
                        else:
                            skip = True
                    if (
                        not skip
                        and self.group_exclude
                        and re.search(self.group_exclude, group)
                    ):
                        skip = True
                        logging.debug(f"Group to exclude: `{group}`.")

                    title = current_item.get("title", "")
                    if not skip and self.channel_include:
                        if re.search(self.channel_include, title):
                            logging.debug(f"Channel to include: `{title}`.")
                        else:
                            skip = True
                    if (
                        not skip
                        and self.channel_exclude
                        and re.search(self.channel_exclude, title)
                    ):
                        skip = True
                        logging.debug(f"Channel to exclude: `{title}`.")

                else:
                    if is_template:
                        template_order = template_order + 1
                        for url in self.id_url.get(current_id, []):
                            current_params = current_item["params"]
                            current_params["template-order"] = template_order
                            self.data[url]["params"].update(current_params)
                            self.data[url]["title"] = current_item["title"]
                    else:
                        if self.udpxy:
                            line = utils.convert_url_with_udpxy(line, self.udpxy)
                        current_item["source"] = source_name
                        self.data[line] = current_item

                        if current_id not in self.id_url:
                            self.id_url[current_id] = []
                        self.id_url[current_id].append(line)

    def filter(self) -> None:
        """Filter process."""
        urls = list(self.data.keys())
        random.shuffle(urls)
        pbar = tqdm(urls, ascii=True)
        for url in pbar:
            status = "OK"
            time.sleep(self.interval)
            if self.skip_connectivity_check:
                status = "Skipped"
            elif self.max_height or self.min_height or self.resolution_on_title:
                height = utils.check_stream(url, self.timeout)
                if height == 0:
                    self.inaccessible_urls.add(url)
                    status = "Inaccessible (0 height)"
                elif height < self.min_height:
                    self.low_res_urls.add(url)
                    status = "Low Resolution"
                elif (
                    self.max_height != defaults.MAX_HEIGHT and height > self.max_height
                ):
                    self.high_res_urls.add(url)
                    status = "High Resolution"
                self.data[url]["height"] = height
            elif not utils.check_connectivity(url, self.timeout):
                self.inaccessible_urls.add(url)
                status = "Inaccessible (No connectivity)"
            pbar.write(f"{url}, {status}!")

    def __custom_sort(self, url: str) -> list[Any]:
        """Sort by tvg-id, resolution, template-order and title."""
        res = []
        for key in self.sort_keys:
            entry = self.data[url]
            if key == "height":
                res.append(-entry.get(key, 0))
            elif key == "title":
                res.append(entry.get(key, ""))
            elif key == "tvg-id":
                res.append(
                    int(re.sub(r"\D", "", entry["params"].get(key, "")) or sys.maxsize)
                )
            elif key == "template-order":
                res.append(int(entry["params"].get(key) or sys.maxsize))
            elif key == "group-title":
                res.append(entry["params"].get(key) or "")
        return res


================================================
FILE: src/iptvtools/parsers.py
================================================
#!/usr/bin/env python
"""Simplified parser for m3u8 file.

File: parser.py
Author: huxuan
Email: i(at)huxuan.org
"""

import os.path
import re
import tempfile
from collections.abc import Iterator
from typing import Any

import requests

from iptvtools.constants import patterns


def parse_content_to_lines(content: str, timeout: int | None = None) -> Iterator[str]:
    """Universal interface to split content into lines."""
    if os.path.isfile(content):
        with open(content, encoding="utf-8") as fp:
            for line in fp:
                yield re.sub(r"[^\S ]+", "", line.strip())
    else:
        with tempfile.TemporaryFile(mode="w+t") as fp:
            fp.write(requests.get(content, timeout=timeout).text)
            fp.seek(0)
            for line in fp:
                yield re.sub(r"[^\S ]+", "", line.strip())


def parse_tag_inf(line: str) -> dict[str, Any]:
    """Parse INF content."""
    match = patterns.EXTINF.fullmatch(line)
    res = match and match.groupdict() or {}
    if "params" in res:
        res["params"] = dict(patterns.PARAMS.findall(res["params"]))
    return res


def parse_tag_m3u(line: str) -> dict[str, Any]:
    """Parse M3U content."""
    match = patterns.EXTM3U.fullmatch(line)
    return match and match.groupdict() or {}


================================================
FILE: src/iptvtools/py.typed
================================================


================================================
FILE: src/iptvtools/settings.py
================================================
"""Settings Module."""

import logging
from logging import getLevelName

from pydantic_settings import BaseSettings, SettingsConfigDict


class GlobalSettings(BaseSettings):
    """System level settings."""

    ci: bool = False
    """Indicator for whether or not in CI/CD environment."""


class Settings(BaseSettings):
    """Project specific settings."""

    logging_level: str | None = getLevelName(logging.INFO)
    """Default logging level for the project."""

    model_config = SettingsConfigDict(
        env_prefix="IPTVTOOLS_",
    )


# NOTE(huxuan): `#:` style docstring is required for module attributes to satisfy both
# autodoc [1] and `check-docstring-first` in `pre-commit` [2].
# [1] https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#directive-autoattribute
# [2] https://github.com/pre-commit/pre-commit-hooks/issues/159#issuecomment-559886109

#: Instance for system level settings.
global_settings = GlobalSettings()

#: Instance for project specific settings.
settings = Settings()


================================================
FILE: src/iptvtools/utils.py
================================================
#!/usr/bin/env python
"""Relevant Utilities.

File: utils.py
Author: huxuan
Email: i(at)huxuan.org
"""

import json
import logging
import socket
import struct
from subprocess import (
    PIPE,
    Popen,
    TimeoutExpired,
)
from typing import Any
from urllib.parse import urlparse

import requests

from iptvtools.config import Config

PROBE_COMMAND = (
    "ffprobe -hide_banner -show_streams -select_streams v -of json=c=1 -v quiet"
)

UDP_SCHEME = (
    "udp",
    "rtp",
)


def convert_url_with_udpxy(orig_url: str, udpxy: str) -> str:
    """Convert url with udpxy."""
    parsed_url = urlparse(orig_url)
    if parsed_url.scheme in UDP_SCHEME:
        return f"{udpxy}/{parsed_url.scheme}/{parsed_url.netloc}"
    return orig_url


def unify_title_and_id(item: dict[str, Any]) -> dict[str, Any]:
    """Unify title and id."""
    for title_unifier in sorted(Config.title_unifiers):
        if title_unifier in item["title"]:
            item["title"] = item["title"].replace(
                title_unifier, Config.title_unifiers[title_unifier]
            )

    if "tvg-name" in item.get("params", {}):
        item["id"] = item["params"]["tvg-name"]
    else:
        item["id"] = item["title"]

    for id_unifier in sorted(Config.id_unifiers):
        if id_unifier in item["id"]:
            item["id"] = item["id"].replace(id_unifier, Config.id_unifiers[id_unifier])

    return item


def probe(url: str, timeout: int | None = None) -> Any:
    """Invoke probe to get stream information."""
    outs = None
    with Popen(  # noqa: S603
        f"{PROBE_COMMAND} {url}".split(), stdout=PIPE, stderr=PIPE
    ) as proc:
        try:
            outs, _ = proc.communicate(timeout=timeout)
        except TimeoutExpired:
            proc.kill()
    if outs:
        try:
            return json.loads(outs.decode("utf-8"))
        except json.JSONDecodeError as exc:
            logging.error(exc)
    return None


def check_stream(url: str, timeout: int | None = None) -> int:
    """Check stream information and return height."""
    stream_info = probe(url, timeout)
    if stream_info and stream_info.get("streams"):
        return max([int(stream.get("height", 0)) for stream in stream_info["streams"]])
    return 0


def check_connectivity(url: str, timeout: int | None = None) -> bool:
    """Check connectivity."""
    parsed_url = urlparse(url)
    if parsed_url.scheme in UDP_SCHEME:
        return check_udp_connectivity(parsed_url.netloc, timeout)
    return check_http_connectivity(url, timeout)


def check_udp_connectivity(url: str, timeout: int | None = None) -> bool:
    """Check UDP connectivity."""
    ipaddr, port = url.rsplit(":", 1)
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.settimeout(timeout)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
    sock.bind(("", int(port)))
    mreq = struct.pack("4sl", socket.inet_aton(ipaddr), socket.INADDR_ANY)
    sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
    try:
        if sock.recv(10240):
            return True
    except TimeoutError:
        pass
    return False


def check_http_connectivity(url: str, timeout: int | None = None) -> bool:
    """Check HTTP connectivity."""
    try:
        return requests.get(url, timeout=timeout, stream=True).ok
    except requests.RequestException:
        return False


def height_to_resolution(height: int) -> str:
    """Convert height to resolution."""
    if not height:
        return ""
    if height >= 4320:
        return "8K"
    if height >= 2160:
        return "4K"
    if height >= 1080:
        return "1080p"
    if height >= 720:
        return "720p"
    return f"{height}p"


================================================
FILE: tests/__init__.py
================================================
"""Init for the test."""


================================================
FILE: tests/cli_test.py
================================================
"""Test for cli."""

from click.testing import CliRunner

from iptvtools.cli import cli


def test_cli() -> None:
    """Test for cli."""
    runner = CliRunner()
    result = runner.invoke(cli)
    assert result.exit_code == 0
    assert "Usage" in result.output


def test_cli_filter_help() -> None:
    """Test the help for filter subcommand of the cli."""
    runner = CliRunner()
    result = runner.invoke(cli, ["filter", "--help"])
    assert result.exit_code == 0
    assert "Show this message and exit." in result.output


================================================
FILE: tests/pkg_test.py
================================================
"""Test for pkg."""

import iptvtools


def test_pkg() -> None:
    """Test for pkg."""
    assert iptvtools.__package__ == "iptvtools"


================================================
FILE: tests/settings_test.py
================================================
"""Test for settings."""

import os

from iptvtools.settings import global_settings, settings


def test_settings() -> None:
    """Test for settings."""
    assert settings.logging_level == os.getenv(
        "IPTVTOOLS_LOGGING_LEVEL",
        "INFO",
    )
    assert str(global_settings.ci).lower() == os.getenv("CI", "False").lower()
Download .txt
gitextract_nwhxt54e/

├── .copier-answers.yml
├── .devcontainer/
│   ├── Dockerfile
│   ├── Dockerfile.dockerignore
│   └── devcontainer.json
├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── ci.yml
│       ├── commitlint.yml
│       ├── delete-untagged-packages.yml
│       ├── devcontainer.yml
│       ├── readthedocs-preview.yml
│       ├── release.yml
│       ├── renovate.yml
│       └── semantic-release.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .readthedocs.yaml
├── .releaserc.json
├── .renovaterc.json
├── .vscode/
│   ├── extensions.json
│   └── settings.json
├── LICENSE
├── Makefile
├── README.md
├── config.json
├── docs/
│   ├── advanced/
│   │   ├── cicd.md
│   │   ├── dev-containers.md
│   │   ├── index.md
│   │   └── partial-dev-env.md
│   ├── api/
│   │   ├── index.md
│   │   └── settings.md
│   ├── cli/
│   │   ├── filter.md
│   │   ├── index.md
│   │   └── iptvtools.md
│   ├── conf.py
│   ├── development/
│   │   ├── cleanup-dev-env.md
│   │   ├── commit.md
│   │   ├── git-workflow.md
│   │   ├── index.md
│   │   ├── setup-dev-env.md
│   │   └── tests.md
│   ├── index.md
│   ├── management/
│   │   ├── index.md
│   │   ├── init.md
│   │   ├── release.md
│   │   ├── settings.md
│   │   └── update.md
│   ├── reports/
│   │   ├── coverage/
│   │   │   └── index.md
│   │   ├── index.md
│   │   └── mypy/
│   │       └── index.md
│   └── usage/
│       ├── filter.md
│       └── index.md
├── pyproject.toml
├── scripts/
│   └── generate-coverage-badge.sh
├── src/
│   └── iptvtools/
│       ├── __init__.py
│       ├── cli.py
│       ├── config.py
│       ├── constants/
│       │   ├── __init__.py
│       │   ├── defaults.py
│       │   ├── helps.py
│       │   ├── patterns.py
│       │   └── tags.py
│       ├── exceptions.py
│       ├── models.py
│       ├── parsers.py
│       ├── py.typed
│       ├── settings.py
│       └── utils.py
└── tests/
    ├── __init__.py
    ├── cli_test.py
    ├── pkg_test.py
    └── settings_test.py
Download .txt
SYMBOL INDEX (33 symbols across 10 files)

FILE: src/iptvtools/cli.py
  function cli (line 18) | def cli() -> None:
  function filter (line 68) | def filter(

FILE: src/iptvtools/config.py
  class MetaConfig (line 16) | class MetaConfig(type):
    method init (line 22) | def init(cls, config_file: str | Path) -> None:
    method __getattr__ (line 28) | def __getattr__(cls, key: str) -> Any:
  class Config (line 33) | class Config(metaclass=MetaConfig):  # pylint: disable=R0903

FILE: src/iptvtools/exceptions.py
  class BaseCustomException (line 10) | class BaseCustomException(RuntimeError):
  class FFmpegNotInstalledError (line 14) | class FFmpegNotInstalledError(BaseCustomException):
    method __init__ (line 17) | def __init__(self) -> None:

FILE: src/iptvtools/models.py
  class Playlist (line 23) | class Playlist:
    method __init__ (line 26) | def __init__(
    method export (line 69) | def export(self) -> None:
    method parse (line 101) | def parse(self) -> None:
    method _parse (line 108) | def _parse(self, sources: list[str], is_template: bool = False) -> None:
    method filter (line 181) | def filter(self) -> None:
    method __custom_sort (line 210) | def __custom_sort(self, url: str) -> list[Any]:

FILE: src/iptvtools/parsers.py
  function parse_content_to_lines (line 20) | def parse_content_to_lines(content: str, timeout: int | None = None) -> ...
  function parse_tag_inf (line 34) | def parse_tag_inf(line: str) -> dict[str, Any]:
  function parse_tag_m3u (line 43) | def parse_tag_m3u(line: str) -> dict[str, Any]:

FILE: src/iptvtools/settings.py
  class GlobalSettings (line 9) | class GlobalSettings(BaseSettings):
  class Settings (line 16) | class Settings(BaseSettings):

FILE: src/iptvtools/utils.py
  function convert_url_with_udpxy (line 35) | def convert_url_with_udpxy(orig_url: str, udpxy: str) -> str:
  function unify_title_and_id (line 43) | def unify_title_and_id(item: dict[str, Any]) -> dict[str, Any]:
  function probe (line 63) | def probe(url: str, timeout: int | None = None) -> Any:
  function check_stream (line 81) | def check_stream(url: str, timeout: int | None = None) -> int:
  function check_connectivity (line 89) | def check_connectivity(url: str, timeout: int | None = None) -> bool:
  function check_udp_connectivity (line 97) | def check_udp_connectivity(url: str, timeout: int | None = None) -> bool:
  function check_http_connectivity (line 115) | def check_http_connectivity(url: str, timeout: int | None = None) -> bool:
  function height_to_resolution (line 123) | def height_to_resolution(height: int) -> str:

FILE: tests/cli_test.py
  function test_cli (line 8) | def test_cli() -> None:
  function test_cli_filter_help (line 16) | def test_cli_filter_help() -> None:

FILE: tests/pkg_test.py
  function test_pkg (line 6) | def test_pkg() -> None:

FILE: tests/settings_test.py
  function test_settings (line 8) | def test_settings() -> None:
Condensed preview — 71 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (133K chars).
[
  {
    "path": ".copier-answers.yml",
    "chars": 1946,
    "preview": "_commit: v0.0.66\n_src_path: gh:serious-scaffold/ss-python\nauthor_email: i@huxuan.org\nauthor_name: huxuan\ncopyright_holde"
  },
  {
    "path": ".devcontainer/Dockerfile",
    "chars": 3576,
    "preview": "# syntax=docker/dockerfile:1\n\nARG PYTHON_VERSION=3.12\n\n#################################################################"
  },
  {
    "path": ".devcontainer/Dockerfile.dockerignore",
    "chars": 63,
    "preview": "*\n.*\n!/Makefile\n!/README.md\n!/pdm.lock\n!/pyproject.toml\n!/src/\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "chars": 1465,
    "preview": "{\n    \"customizations\": {\n        // Configure extensions specific to VS Code.\n        \"vscode\": {\n            \"extensio"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 19,
    "preview": "github:\n  - huxuan\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 1092,
    "preview": "name: CI\n\non:\n  pull_request:\n  push:\n    branches:\n      - main\n\nconcurrency:\n  cancel-in-progress: true\n  group: ${{ g"
  },
  {
    "path": ".github/workflows/commitlint.yml",
    "chars": 884,
    "preview": "name: CommitLint\nconcurrency:\n  cancel-in-progress: true\n  group: ${{ github.workflow }}-${{ github.ref }}\njobs:\n  commi"
  },
  {
    "path": ".github/workflows/delete-untagged-packages.yml",
    "chars": 1056,
    "preview": "name: Delete Untagged Packages\n\non:\n  schedule:\n    - cron: \"0 2 * * 0\"\n  workflow_dispatch: null\n\npermissions:\n  packag"
  },
  {
    "path": ".github/workflows/devcontainer.yml",
    "chars": 2923,
    "preview": "name: DevContainer\n\non:\n  pull_request:\n    paths:\n      - .devcontainer/Dockerfile\n      - .devcontainer/Dockerfile.doc"
  },
  {
    "path": ".github/workflows/readthedocs-preview.yml",
    "chars": 663,
    "preview": "name: Read the Docs Pull Request Preview\nconcurrency:\n  cancel-in-progress: true\n  group: ${{ github.workflow }}-${{ git"
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 5166,
    "preview": "name: Release\n\non:\n  release:\n    types:\n      - published\n\nconcurrency:\n  cancel-in-progress: true\n  group: ${{ github."
  },
  {
    "path": ".github/workflows/renovate.yml",
    "chars": 2628,
    "preview": "name: Renovate\njobs:\n  renovate:\n    container:\n      env:\n        LOG_LEVEL: debug\n        RENOVATE_ALLOWED_POST_UPGRAD"
  },
  {
    "path": ".github/workflows/semantic-release.yml",
    "chars": 1904,
    "preview": "name: Semantic Release\n\non:\n  workflow_run:\n    workflows: [CI]\n    types: [completed]\n    branches: [main]\n\njobs:\n  sem"
  },
  {
    "path": ".gitignore",
    "chars": 3186,
    "preview": "# Custom\n*.m3u\n*.swp\n.DS_Store\nPipfile\npublic\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class"
  },
  {
    "path": ".pre-commit-config.yaml",
    "chars": 3175,
    "preview": "default_install_hook_types:\n  - post-checkout\n  - post-merge\n  - post-rewrite\n  - pre-push\ndefault_stages:\n  - manual\n  "
  },
  {
    "path": ".readthedocs.yaml",
    "chars": 1278,
    "preview": "build:\n  apt_packages:\n    - pipx\n  jobs:\n    post_checkout:\n      - git fetch --unshallow || true\n      # Cancel buildi"
  },
  {
    "path": ".releaserc.json",
    "chars": 4178,
    "preview": "{\n    \"plugins\": [\n        [\n            \"@semantic-release/commit-analyzer\",\n            {\n                \"releaseRule"
  },
  {
    "path": ".renovaterc.json",
    "chars": 4994,
    "preview": "{\n    \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n    \"constraints\": {\n        \"copier\": \"==9.4.1\",\n"
  },
  {
    "path": ".vscode/extensions.json",
    "chars": 319,
    "preview": "{\n  \"recommendations\": [\n    \"DavidAnson.vscode-markdownlint\",\n    \"ExecutableBookProject.myst-highlight\",\n    \"charlier"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 2090,
    "preview": "{\n  \"[jsonc]\": {\n    \"editor.defaultFormatter\": \"vscode.json-language-features\"\n  },\n  \"[markdown]\": {\n    \"editor.defau"
  },
  {
    "path": "LICENSE",
    "chars": 1068,
    "preview": "MIT License\n\nCopyright (c) 2019-2025 huxuan\n\nPermission is hereby granted, free of charge, to any person obtaining a cop"
  },
  {
    "path": "Makefile",
    "chars": 5869,
    "preview": ".PHONY: clean deepclean install dev prerequisites mypy ruff ruff-format pyproject-fmt codespell lint pre-commit test-run"
  },
  {
    "path": "README.md",
    "chars": 4651,
    "preview": "# IPTVTools\n\nA set of scripts that help to better IPTV experience.\n\n[![CI](https://github.com/huxuan/iptvtools/actions/w"
  },
  {
    "path": "config.json",
    "chars": 685,
    "preview": "{\n    \"id_unifiers\": {\n        \"-\": \"\",\n        \"IPTV\": \"\",\n        \"北京纪实\": \"BTV冬奥纪实\",\n        \"BTV北京卫视\": \"北京卫视\",\n      "
  },
  {
    "path": "docs/advanced/cicd.md",
    "chars": 2587,
    "preview": "# CI/CD Configurations\n\nThe CI/CD (Continuous Integration and Continuous Delivery) workflows automate various developmen"
  },
  {
    "path": "docs/advanced/dev-containers.md",
    "chars": 3101,
    "preview": "# Development Container\n\nInstead of manually configuring your development environment, [Dev Containers](https://containe"
  },
  {
    "path": "docs/advanced/index.md",
    "chars": 259,
    "preview": "# Advanced Usage\n\nThis section provides recommended best practices for enhancing your development workflow. While not es"
  },
  {
    "path": "docs/advanced/partial-dev-env.md",
    "chars": 1223,
    "preview": "# Partially Set Up Development Environment\n\nIn certain cases, it is unnecessary to install all dependencies as well as t"
  },
  {
    "path": "docs/api/index.md",
    "chars": 56,
    "preview": "# API Reference\n\n```{toctree}\n:maxdepth: 1\nsettings\n```\n"
  },
  {
    "path": "docs/api/settings.md",
    "chars": 75,
    "preview": "# iptvtools.settings\n\n```{eval-rst}\n.. automodule:: iptvtools.settings\n```\n"
  },
  {
    "path": "docs/cli/filter.md",
    "chars": 116,
    "preview": "# IPTVTools Filter\n\n```{eval-rst}\n.. click:: iptvtools.cli:filter\n  :prog: iptvtools-cli filter\n  :nested: full\n```\n"
  },
  {
    "path": "docs/cli/index.md",
    "chars": 64,
    "preview": "# CLI Reference\n\n```{toctree}\n:maxdepth: 1\niptvtools\nfilter\n```\n"
  },
  {
    "path": "docs/cli/iptvtools.md",
    "chars": 100,
    "preview": "# IPTVTools\n\n```{eval-rst}\n.. click:: iptvtools.cli:cli\n  :prog: iptvtools-cli\n  :nested: short\n```\n"
  },
  {
    "path": "docs/conf.py",
    "chars": 2371,
    "preview": "\"\"\"Configuration file for the Sphinx documentation builder.\n\nFor the full list of built-in configuration values, see the"
  },
  {
    "path": "docs/development/cleanup-dev-env.md",
    "chars": 1053,
    "preview": "# Clean Up Development Environment\n\nWhen encountering environment-related problems, a straightforward solution is to cle"
  },
  {
    "path": "docs/development/commit.md",
    "chars": 2616,
    "preview": "# Commit Convention\n\nUsing structured commit messages, we can enhance the readability of our project history, simplify a"
  },
  {
    "path": "docs/development/git-workflow.md",
    "chars": 2911,
    "preview": "# Git Workflow\n\nThis pages shows the recommended Git workflow to keep the local repository clean and organized while ens"
  },
  {
    "path": "docs/development/index.md",
    "chars": 332,
    "preview": "# Development Practices\n\nThis section is designed for developers and covers essential topics during daily development li"
  },
  {
    "path": "docs/development/setup-dev-env.md",
    "chars": 1088,
    "preview": "# Set Up Development Environment\n\nThis page shows the approach to set up development environment. To simplify the proces"
  },
  {
    "path": "docs/development/tests.md",
    "chars": 1490,
    "preview": "# Tests\n\nIn the context of CI/CD automation, dependency updates, and the release process, tests play a crucial role in d"
  },
  {
    "path": "docs/index.md",
    "chars": 359,
    "preview": "# Welcome to IPTVTools's documentation\n\n```{toctree}\n:hidden:\nOverview <self>\nusage/index\nmanagement/index\ndevelopment/i"
  },
  {
    "path": "docs/management/index.md",
    "chars": 269,
    "preview": "# Project Management\n\nThis section is designed for project maintainers and covers essential tasks for managing your proj"
  },
  {
    "path": "docs/management/init.md",
    "chars": 1038,
    "preview": "# Project Initialization\n\n## Prerequisites\n\n[pipx](https://pipx.pypa.io/) is required to manage the standalone tools use"
  },
  {
    "path": "docs/management/release.md",
    "chars": 3716,
    "preview": "# Release Process\n\nWith the integration of [semantic-release](https://github.com/semantic-release/semantic-release), the"
  },
  {
    "path": "docs/management/settings.md",
    "chars": 7900,
    "preview": "# Repository Settings\n\nThere are several settings to utilize the features provided by the project template. Although som"
  },
  {
    "path": "docs/management/update.md",
    "chars": 3817,
    "preview": "# Template and Dependency Update\n\n## Template update\n\nTo update the project template, thanks to the [update feature](htt"
  },
  {
    "path": "docs/reports/coverage/index.md",
    "chars": 72,
    "preview": "# Coverage Reports\n\n<!-- placeholder for generated coverage reports -->\n"
  },
  {
    "path": "docs/reports/index.md",
    "chars": 80,
    "preview": "# Code Quality Reports\n\n```{toctree}\n:maxdepth: 2\nmypy/index\ncoverage/index\n```\n"
  },
  {
    "path": "docs/reports/mypy/index.md",
    "chars": 64,
    "preview": "# MyPy Reports\n\n<!-- placeholder for generated mypy reports -->\n"
  },
  {
    "path": "docs/usage/filter.md",
    "chars": 4292,
    "preview": "# Filter\n\n## Reference\n\n<project:/cli/filter.md>\n\n## Example\n\nThere is a [well-maintained IPTV list](https://gist.github"
  },
  {
    "path": "docs/usage/index.md",
    "chars": 33,
    "preview": "# Usage\n\n```{toctree}\nfilter\n```\n"
  },
  {
    "path": "pyproject.toml",
    "chars": 3007,
    "preview": "[build-system]\nbuild-backend = \"setuptools.build_meta\"\nrequires = [\n    \"setuptools==75.8.0\",\n    \"setuptools-scm==8.2.0"
  },
  {
    "path": "scripts/generate-coverage-badge.sh",
    "chars": 534,
    "preview": "#!/bin/bash\n\nTOTAL_COVERAGE=$(coverage report --format=total)\nCOLOR=\"#9f9f9f\"\n\nif [ \"$TOTAL_COVERAGE\" -gt 95 ]; then\n   "
  },
  {
    "path": "src/iptvtools/__init__.py",
    "chars": 28,
    "preview": "\"\"\"Init for the project.\"\"\"\n"
  },
  {
    "path": "src/iptvtools/cli.py",
    "chars": 3560,
    "preview": "\"\"\"Command Line Interface.\"\"\"\n\nimport logging\nimport shutil\n\nimport click\n\nfrom iptvtools import exceptions\nfrom iptvtoo"
  },
  {
    "path": "src/iptvtools/config.py",
    "chars": 761,
    "preview": "#!/usr/bin/env python\n\"\"\"Configuration for iptvtools.\n\nFile: config.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\"\n\nimport"
  },
  {
    "path": "src/iptvtools/constants/__init__.py",
    "chars": 111,
    "preview": "#!/usr/bin/env python\n\"\"\"Constants for iptvtools.\n\nFile: __init__.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\"\n"
  },
  {
    "path": "src/iptvtools/constants/defaults.py",
    "chars": 387,
    "preview": "#!/usr/bin/env python\n\"\"\"Defaults for iptvtools.\n\nFile: constants.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\"\n\nCONFIG ="
  },
  {
    "path": "src/iptvtools/constants/helps.py",
    "chars": 1708,
    "preview": "#!/usr/bin/env python\n\"\"\"Helps for iptvtools.\n\nFile: constants.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\"\n\nCONFIG = \"C"
  },
  {
    "path": "src/iptvtools/constants/patterns.py",
    "chars": 297,
    "preview": "#!/usr/bin/env python\n\"\"\"Patterns for iptvtools.\n\nFile: constants.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\"\n\nimport r"
  },
  {
    "path": "src/iptvtools/constants/tags.py",
    "chars": 140,
    "preview": "#!/usr/bin/env python\n\"\"\"Tags for iptvtools.\n\nFile: constants.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\"\n\nM3U = \"#EXTM"
  },
  {
    "path": "src/iptvtools/exceptions.py",
    "chars": 579,
    "preview": "#!/usr/bin/env python\n\"\"\"Custom exceptions for iptvtools.\n\nFile: exceptions.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\""
  },
  {
    "path": "src/iptvtools/models.py",
    "chars": 8600,
    "preview": "#!/usr/bin/env python\n\"\"\"Playlist which contains all the channels' information.\n\nFile: models.py\nAuthor: huxuan\nEmail: i"
  },
  {
    "path": "src/iptvtools/parsers.py",
    "chars": 1281,
    "preview": "#!/usr/bin/env python\n\"\"\"Simplified parser for m3u8 file.\n\nFile: parser.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\"\n\nim"
  },
  {
    "path": "src/iptvtools/py.typed",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/iptvtools/settings.py",
    "chars": 1024,
    "preview": "\"\"\"Settings Module.\"\"\"\n\nimport logging\nfrom logging import getLevelName\n\nfrom pydantic_settings import BaseSettings, Set"
  },
  {
    "path": "src/iptvtools/utils.py",
    "chars": 3774,
    "preview": "#!/usr/bin/env python\n\"\"\"Relevant Utilities.\n\nFile: utils.py\nAuthor: huxuan\nEmail: i(at)huxuan.org\n\"\"\"\n\nimport json\nimpo"
  },
  {
    "path": "tests/__init__.py",
    "chars": 25,
    "preview": "\"\"\"Init for the test.\"\"\"\n"
  },
  {
    "path": "tests/cli_test.py",
    "chars": 530,
    "preview": "\"\"\"Test for cli.\"\"\"\n\nfrom click.testing import CliRunner\n\nfrom iptvtools.cli import cli\n\n\ndef test_cli() -> None:\n    \"\""
  },
  {
    "path": "tests/pkg_test.py",
    "chars": 136,
    "preview": "\"\"\"Test for pkg.\"\"\"\n\nimport iptvtools\n\n\ndef test_pkg() -> None:\n    \"\"\"Test for pkg.\"\"\"\n    assert iptvtools.__package__"
  },
  {
    "path": "tests/settings_test.py",
    "chars": 338,
    "preview": "\"\"\"Test for settings.\"\"\"\n\nimport os\n\nfrom iptvtools.settings import global_settings, settings\n\n\ndef test_settings() -> N"
  }
]

About this extraction

This page contains the full source code of the huxuan/iptvtools GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 71 files (119.9 KB), approximately 31.9k tokens, and a symbol index with 33 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!