├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── doc.yml │ ├── feature_request.yml │ └── question.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── pre-commit.yml │ ├── python-lint.yml │ ├── python-test.yml │ └── youtube-cards.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── action.py ├── action.yml ├── api ├── __init__.py ├── exceptions.py ├── index.py ├── locale │ ├── ar.yml │ ├── bn.yml │ ├── de.yml │ ├── en.yml │ ├── es.yml │ ├── fa.yml │ ├── fr.yml │ ├── he.yml │ ├── hi.yml │ ├── hu.yml │ ├── id.yml │ ├── it.yml │ ├── ja.yml │ ├── ko.yml │ ├── mi.yml │ ├── pt.yml │ ├── sv.yml │ └── ur.yml ├── static │ ├── css │ │ ├── style.css │ │ └── toggle-dark.css │ ├── images │ │ └── favicon.png │ └── js │ │ └── toggle-dark.js ├── templates │ ├── error.svg │ ├── index.html │ ├── main.svg │ └── resources │ │ ├── error.jpg │ │ └── roboto.css ├── utils.py └── validate.py ├── pyproject.toml ├── requirements-action.txt ├── requirements-dev.txt ├── requirements.txt ├── tests ├── __init__.py ├── conftest.py ├── test_action.py ├── test_app.py ├── test_locales.py ├── test_utils.py └── test_validate.py ├── tox.ini └── vercel.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: DenverCoder1 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: jonahlawrence 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug 2 | description: Submit a bug report to help us improve 3 | title: "🐛 Bug: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for taking the time to fill out our bug report form 🙏 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Description 13 | description: A brief description of the bug. What happened? What did you expect to happen? 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: steps 18 | attributes: 19 | label: Steps to reproduce 20 | description: How do you trigger this bug? Please walk us through it step by step. 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: screenshots 25 | attributes: 26 | label: Screenshots 27 | description: Please add screenshots if applicable 28 | validations: 29 | required: false 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/doc.yml: -------------------------------------------------------------------------------- 1 | name: 📚 Documentation 2 | description: Report an issue related to the documentation 3 | title: "📚 Docs: " 4 | labels: ["documentation"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for taking the time to make our documentation better 🙏 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Description 13 | description: Description of the documentation issue 14 | validations: 15 | required: true 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Submit a proposal for a new feature or enhancement 3 | title: "🚀 Feature: " 4 | labels: ["feature"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for taking the time to fill out our feature request form 🙏 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Description 13 | description: Description of the proposed feature or enhancement. Why should this be implemented? 14 | validations: 15 | required: true 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yml: -------------------------------------------------------------------------------- 1 | name: ❓ Question 2 | description: Ask a question about the project 3 | title: "❓ Question: " 4 | labels: ["question"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thanks for taking the time to ask a question! 🙏 9 | - type: textarea 10 | id: description 11 | attributes: 12 | label: Description 13 | description: Description of the question. What would you like to know? 14 | validations: 15 | required: true 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | 5 | ## Type of change 6 | 7 | 8 | 9 | - [ ] Bug fix (added a non-breaking change which fixes an issue) 10 | - [ ] New feature (added a non-breaking change which adds functionality) 11 | - [ ] Updated documentation (updated the readme, templates, or other repo files) 12 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 13 | 14 | ## How Has This Been Tested? 15 | 16 | 17 | 18 | - [ ] Added or updated test cases to test new features 19 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | paths: 11 | - "api/**" 12 | - "tests/**" 13 | - "**.py" 14 | - "**.yml" 15 | - "**.yaml" 16 | - "**.json" 17 | - "**.css" 18 | - "**.svg" 19 | - "**.md" 20 | - "**.toml" 21 | - "requirements*.txt" 22 | 23 | jobs: 24 | pre-commit: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v3 30 | 31 | - name: Set up Python 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: "3.11" 35 | cache: pip 36 | 37 | - name: Install dependencies 38 | run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-action.txt 39 | 40 | - name: Run pre-commit 41 | uses: pre-commit/action@v3.0.0 42 | -------------------------------------------------------------------------------- /.github/workflows/python-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | paths: 11 | - "api/**" 12 | - "tests/**" 13 | - "**.py" 14 | - "**.toml" 15 | - "requirements*.txt" 16 | - ".github/workflows/python-lint.yml" 17 | 18 | jobs: 19 | pyright: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | python-version: ["3.11"] 24 | fail-fast: false 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | 29 | - name: Set up python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v4 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | 34 | - name: Install dependencies 35 | run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-action.txt 36 | 37 | - name: Set up pyright 38 | run: echo "PYRIGHT_VERSION=$(python -c 'import pyright; print(pyright.__pyright_version__)')" >> $GITHUB_ENV 39 | 40 | - name: Run pyright 41 | uses: jakebailey/pyright-action@v1.3.0 42 | with: 43 | version: ${{ env.PYRIGHT_VERSION }} 44 | python-version: ${{ matrix.python-version }} 45 | python-platform: Linux 46 | warnings: true 47 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Test application 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - main 11 | paths-ignore: 12 | - "**.md" 13 | pull_request: 14 | branches: 15 | - main 16 | paths: 17 | - "api/**" 18 | - "tests/**" 19 | - "**.py" 20 | - "**.yml" 21 | - "**.svg" 22 | - "**.toml" 23 | - "requirements*.txt" 24 | 25 | jobs: 26 | tox: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | 32 | - name: Set up Python 33 | uses: actions/setup-python@v4 34 | with: 35 | python-version: "3.11" 36 | 37 | - name: Install dependencies 38 | run: python -m pip install -r requirements.txt -r requirements-dev.txt -r requirements-action.txt 39 | 40 | - name: Run tests 41 | run: tox 42 | -------------------------------------------------------------------------------- /.github/workflows/youtube-cards.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Readme YouTube Cards 2 | on: 3 | schedule: 4 | # Runs every day at 12:00 5 | - cron: "0 12 * * *" 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: DenverCoder1/github-readme-youtube-cards@main 16 | id: youtube-cards 17 | with: 18 | channel_id: UCipSxT7a3rn81vGLw9lqRkg 19 | comment_tag_name: EXAMPLE-YOUTUBE-CARDS 20 | youtube_api_key: ${{ secrets.YOUTUBE_API_KEY }} 21 | show_duration: true 22 | theme_context_light: '{ "background_color": "#ffffff", "title_color": "#24292f", "stats_color": "#57606a" }' 23 | theme_context_dark: '{ "background_color": "#0d1117", "title_color": "#ffffff", "stats_color": "#dedede" }' 24 | max_title_lines: 2 25 | output_type: html 26 | - run: echo OUTPUT '${{ steps.youtube-cards.outputs.markdown }}' 27 | shell: bash 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | generated 74 | docs/source/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # celery beat schedule file 97 | celerybeat-schedule 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # mypy 122 | .mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | 129 | # VS Code Config 130 | .vscode/ 131 | 132 | # Vercel Hosting 133 | .vercel 134 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ## Pre-commit setup 2 | 3 | ci: 4 | autofix_commit_msg: "style: auto fixes from pre-commit hooks" 5 | 6 | repos: 7 | - repo: https://github.com/pycqa/isort 8 | rev: 6.0.1 9 | hooks: 10 | - id: isort 11 | args: ["--profile", "black"] 12 | name: Running isort in all files. 13 | 14 | - repo: https://github.com/psf/black 15 | rev: 25.1.0 16 | hooks: 17 | - id: black 18 | name: Running black in all files. 19 | 20 | - repo: https://github.com/pre-commit/pre-commit-hooks 21 | rev: v5.0.0 22 | hooks: 23 | - id: check-ast 24 | name: Check if python files are valid syntax for the ast parser 25 | - id: check-case-conflict 26 | name: Check for case conflict on file names for case insensitive systems. 27 | - id: check-merge-conflict 28 | name: Check for merge conflict syntax. 29 | - id: check-toml 30 | name: Check TOML files for valid syntax. 31 | - id: check-yaml 32 | name: Check YAML files for valid syntax. 33 | - id: debug-statements 34 | name: Check for debug statements. 35 | 36 | - repo: https://github.com/PyCQA/autoflake 37 | rev: v2.3.1 38 | hooks: 39 | - id: autoflake 40 | name: Remove unused imports with autoflake. 41 | args: ["--in-place", "--remove-all-unused-imports"] 42 | 43 | - repo: https://github.com/pre-commit/mirrors-prettier 44 | rev: v4.0.0-alpha.8 45 | hooks: 46 | - id: prettier 47 | name: Running prettier in non-python files. 48 | types_or: [markdown, yaml, css, json, javascript] 49 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | jonah@freshidea.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Contributions are welcome! Feel free to open an issue or submit a pull request if you have a way to improve this project. 4 | 5 | Make sure your request is meaningful and you have tested the app locally before submitting a pull request. 6 | 7 | ### Installing dependencies 8 | 9 | ```bash 10 | # Dependencies for running the Flask server 11 | pip install -r requirements.txt 12 | # Dependencies for testing and development 13 | pip install -r requirements-dev.txt 14 | # Dependencies for running the action script 15 | pip install -r requirements-action.txt 16 | ``` 17 | 18 | ### Running the Flask server 19 | 20 | ```bash 21 | gunicorn api.index:app 22 | ``` 23 | 24 | ### Running the action Python part of the workflow locally 25 | 26 | ```bash 27 | python action.py --channel=UCipSxT7a3rn81vGLw9lqRkg --comment-tag-name="EXAMPLE-YOUTUBE-CARDS" 28 | ``` 29 | 30 | Any additional arguments can be passed to the script. Run `python action.py -h` to see the full list of arguments. 31 | 32 | ### Running tests 33 | 34 | ```bash 35 | tox 36 | ``` 37 | 38 | ## Contributing translations 39 | 40 | You can contribute to GitHub Readme YouTube Cards by adding translations in the `api/locale` folder. 41 | 42 | To add translations for a new language: 43 | 44 | - Copy the contents of `api/locale/en.yml` file to `api/locale/.yml`, where IDENTIFIER is shorthand for the language you are adding translations for. 45 | - Change the top most yaml key `en:` to `:`. 46 | - Add translations for the strings provided below in the file. Only alter the text enclosed in quotes. 47 | - To test, run the project locally and add `&lang=IDENTIFIER` to a card URL to test if translation works as expected. 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jonah Lawrence 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

GitHub Readme YouTube Cards

4 |

5 | 6 |

7 | Workflow for displaying recent YouTube videos as SVG cards in your readme 8 |

9 | 10 |

11 | 12 | 13 |

14 | 15 | ## Basic Usage 16 | 17 | 1. Add the following snippet to your markdown file where you want the cards to appear. 18 | 19 | ```html 20 | 21 | 22 | ``` 23 | 24 | 2. In your repo, create a `.github` folder and inside create a folder named `workflows` if it does not exist. Then create a file in your `.github/workflows/` folder and give it a name such as `youtube-cards.yml` with the following contents. 25 | 26 | 27 | ```yml 28 | name: GitHub Readme YouTube Cards 29 | on: 30 | schedule: 31 | # Runs every hour, on the hour 32 | - cron: "0 * * * *" 33 | workflow_dispatch: 34 | 35 | jobs: 36 | build: 37 | runs-on: ubuntu-latest 38 | # Allow the job to commit to the repository 39 | permissions: 40 | contents: write 41 | # Run the GitHub Readme YouTube Cards action 42 | steps: 43 | - uses: DenverCoder1/github-readme-youtube-cards@main 44 | with: 45 | channel_id: UCipSxT7a3rn81vGLw9lqRkg 46 | ``` 47 | 48 | 49 | 3. Make sure to change the `channel_id` to [your YouTube channel ID](https://github.com/DenverCoder1/github-readme-youtube-cards/wiki/How-to-Locate-Your-Channel-ID). 50 | 51 | 4. The [cron expression](https://crontab.cronhub.io/) in the example above is set to run at the top of every hour. The first time, you may want to [trigger the workflow manually](https://github.com/DenverCoder1/github-readme-youtube-cards/wiki/Running-the-GitHub-Action-Manually). 52 | 53 | 5. You're done! Star the repo and share it with friends! ⭐ 54 | 55 | See below for [advanced configuration](#advanced-configuration). 56 | 57 | ## Live Example 58 | 59 | 60 | 61 | 62 | 63 | 64 | GitHub Star Swag Unboxing and Giveaways 65 | 66 | 67 | 68 | 69 | 70 | How To Self-Host GitHub Readme Streak Stats on Vercel 71 | 72 | 73 | 74 | 75 | 76 | Automatically Deploy to Fly.io with GitHub Actions 77 | 78 | 79 | 80 | 81 | 82 | Hosting a Python Discord Bot for Free with Fly.io 83 | 84 | 85 | 86 | 87 | 88 | Making a Wordle Clone Discord Bot with Python (Nextcord) 89 | 90 | 91 | 92 | 93 | 94 | Run Open Source Code in Seconds with GitPod 95 | 96 | 97 | 98 | 99 | 100 | ## Advanced Configuration 101 | 102 | See [action.yml](https://github.com/DenverCoder1/github-readme-youtube-cards/blob/main/action.yml) for full details. 103 | 104 | Check out the [Wiki](https://github.com/DenverCoder1/github-readme-youtube-cards/wiki) for frequently asked questions. 105 | 106 | ### Inputs 107 | 108 | | Option | Description | Default | 109 | | ----------------------------- | ------------------------------------------------- | ------------------------------------------------------- | 110 | | `channel_id` | The channel ID to use for the feed 📺 | "" | 111 | | `playlist_id` | The playlist ID to use for the feed 📺 | "" | 112 | | `lang` | The locale for views and timestamps 💬 | "en" | 113 | | `comment_tag_name` | The text in the comment tag for replacing content | "YOUTUBE-CARDS" | 114 | | `youtube_api_key` | The API key to use for features marked with 🔑 | "" | 115 | | `max_videos` | The maximum number of videos to display | 6 | 116 | | `base_url` | The base URL to use for the cards | "https://ytcards.demolab.com/" | 117 | | `card_width` | The width of the SVG cards in pixels | 250 | 118 | | `border_radius` | The border radius of the SVG cards | 5 | 119 | | `background_color` | The background color of the SVG cards | "#0d1117" | 120 | | `title_color` | The color of the title text | "#ffffff" | 121 | | `stats_color` | The color of the stats text | "#dedede" | 122 | | `theme_context_light` | JSON object with light mode colors 🎨 | "{}" | 123 | | `theme_context_dark` | JSON object with dark mode colors 🎨 | "{}" | 124 | | `max_title_lines` | The maximum number of lines to use for the title | 1 | 125 | | `show_duration` 🔑 | Whether to show the duration of the videos | "false" | 126 | | `author_name` | The name of the commit author | "GitHub Actions" | 127 | | `author_email` | The email address of the commit author | "41898282+github-actions[bot]@users.noreply.github.com" | 128 | | `commit_message` | The commit message to use for the commit | "docs(readme): Update YouTube cards" | 129 | | `readme_path` | The path to the Markdown or HTML file to update | "README.md" | 130 | | `output_only` | Whether to skip writing to the readme file | "false" | 131 | | `output_type` | The output syntax to use ("markdown" or "html") | "markdown" | 132 | 133 | 📺 A Channel ID or Playlist ID is required. See [How to Locate Your Channel ID](https://github.com/DenverCoder1/github-readme-youtube-cards/wiki/How-to-Locate-Your-Channel-ID) in the wiki for more information. To filter videos by type such as removing shorts or showing only popular videos, see [How to Filter Videos by Type](https://github.com/DenverCoder1/github-readme-youtube-cards/wiki/How-to-Filter-Videos-by-Type). 134 | 135 | 🔑 Some features require a YouTube API key. See [Setting Up the Action with a YouTube API Key](https://github.com/DenverCoder1/github-readme-youtube-cards/wiki/Setting-Up-the-Action-with-a-YouTube-API-Key) in the wiki for more information. 136 | 137 | 🎨 See [Setting Theme Contexts for Light and Dark Mode](https://github.com/DenverCoder1/github-readme-youtube-cards/wiki/Setting-Theme-Contexts-for-Light-and-Dark-Mode) in the wiki for more information. 138 | 139 | 💬 See [this directory](https://github.com/DenverCoder1/github-readme-youtube-cards/tree/main/api/locale) for a list of locales with the word "views" translated. The timestamps will still be translated using [Babel](https://github.com/python-babel/babel) even if a translation file is not present. See [issue #48](https://github.com/DenverCoder1/github-readme-youtube-cards/issues/48) for info on contributing translations. 140 | 141 | [key]: https://user-images.githubusercontent.com/20955511/189419733-84384135-c5c4-4a20-a439-f832d5ad5f5d.png 142 | 143 | ### Outputs 144 | 145 | | Output | Description | 146 | | ----------------- | ------------------------------------------------------------------ | 147 | | `markdown` | The generated Markdown or HTML used for updating the README file | 148 | | `committed` | Whether the action has created a commit (`true` or `false`) | 149 | | `commit_long_sha` | The full SHA of the commit that has just been created | 150 | | `commit_sha` | The short 7-character SHA of the commit that has just been created | 151 | | `pushed` | Whether the action has pushed to the remote (`true` or `false`) | 152 | 153 | See [Using the Markdown as an Action Output](https://github.com/DenverCoder1/github-readme-youtube-cards/wiki/Using-the-Markdown-as-an-Action-Output) for more information. 154 | 155 | ### Example Workflow 156 | 157 | This is an advanced example showing the available options. All options are optional except `channel_id`. 158 | 159 | ```yaml 160 | name: GitHub Readme YouTube Cards 161 | on: 162 | schedule: 163 | # Runs every hour, on the hour 164 | - cron: "0 * * * *" 165 | workflow_dispatch: 166 | 167 | jobs: 168 | build: 169 | runs-on: ubuntu-latest 170 | # Allow the job to commit to the repository 171 | permissions: 172 | contents: write 173 | # Run the GitHub Readme YouTube Cards action 174 | steps: 175 | - uses: DenverCoder1/github-readme-youtube-cards@main 176 | with: 177 | channel_id: UCipSxT7a3rn81vGLw9lqRkg 178 | lang: en 179 | comment_tag_name: YOUTUBE-CARDS 180 | youtube_api_key: ${{ secrets.YOUTUBE_API_KEY }} # Configured in Actions Secrets (see Wiki) 181 | max_videos: 6 182 | base_url: https://ytcards.demolab.com/ 183 | card_width: 250 184 | border_radius: 5 185 | background_color: "#0d1117" 186 | title_color: "#ffffff" 187 | stats_color: "#dedede" 188 | theme_context_light: '{ "background_color": "#ffffff", "title_color": "#24292f", "stats_color": "#57606a" }' 189 | theme_context_dark: '{ "background_color": "#0d1117", "title_color": "#ffffff", "stats_color": "#dedede" }' 190 | max_title_lines: 2 191 | show_duration: true # Requires YouTube API Key (see Wiki) 192 | author_name: GitHub Actions 193 | author_email: 41898282+github-actions[bot]@users.noreply.github.com 194 | commit_message: "docs(readme): Update YouTube cards" 195 | readme_path: README.md 196 | output_only: false 197 | output_type: markdown 198 | ``` 199 | 200 | ### Example Playlist Workflow 201 | 202 | This is an example workflow for using a playlist instead of a channel. 203 | 204 | ```yaml 205 | name: GitHub Readme YouTube Cards 206 | on: 207 | schedule: 208 | # Runs every hour, on the hour 209 | - cron: "0 * * * *" 210 | workflow_dispatch: 211 | 212 | jobs: 213 | build: 214 | runs-on: ubuntu-latest 215 | # Allow the job to commit to the repository 216 | permissions: 217 | contents: write 218 | # Run the GitHub Readme YouTube Cards action 219 | steps: 220 | - uses: DenverCoder1/github-readme-youtube-cards@main 221 | with: 222 | playlist_id: PL9YUC9AZJGFFAErr_ZdK2FV7sklMm2K0J 223 | ``` 224 | 225 | ## Contributing 226 | 227 | Contributions are welcome! Feel free to open an issue or submit a pull request if you have a way to improve this project. 228 | 229 | Make sure your request is meaningful and you have tested the app locally before submitting a pull request. 230 | 231 | Please check out our [contributing guidelines](/CONTRIBUTING.md) for more information on how to contribute to this project. 232 | 233 | ## 🙋‍♂️ Support 234 | 235 | 💙 If you like this project, give it a ⭐ and share it with friends! 236 | 237 |

238 | Youtube 239 | Sponsor with Github 240 |

241 | 242 | [☕ Buy me a coffee](https://ko-fi.com/jlawrence) 243 | -------------------------------------------------------------------------------- /action.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import time 4 | import urllib.parse 5 | import urllib.request 6 | from argparse import ArgumentParser 7 | from typing import Any, Dict, Optional 8 | 9 | import feedparser 10 | 11 | 12 | class VideoParser: 13 | def __init__( 14 | self, 15 | *, 16 | base_url: str, 17 | channel_id: Optional[str] = None, 18 | playlist_id: Optional[str] = None, 19 | lang: str, 20 | max_videos: int, 21 | card_width: int, 22 | border_radius: int, 23 | background_color: str, 24 | title_color: str, 25 | stats_color: str, 26 | youtube_api_key: Optional[str], 27 | theme_context_light: Dict[str, str], 28 | theme_context_dark: Dict[str, str], 29 | max_title_lines: int, 30 | show_duration: bool, 31 | output_type: str, 32 | ): 33 | self._base_url = base_url 34 | self._channel_id = channel_id 35 | self._playlist_id = playlist_id 36 | self._lang = lang 37 | self._max_videos = max_videos 38 | self._card_width = card_width 39 | self._border_radius = border_radius 40 | self._background_color = background_color 41 | self._title_color = title_color 42 | self._stats_color = stats_color 43 | self._theme_context_light = theme_context_light 44 | self._theme_context_dark = theme_context_dark 45 | self._max_title_lines = max_title_lines 46 | self._youtube_api_key = youtube_api_key 47 | self._show_duration = show_duration 48 | self._output_type = output_type 49 | self._youtube_data = {} 50 | 51 | @staticmethod 52 | def parse_iso8601_duration(duration: str) -> int: 53 | """Parse ISO 8601 duration and return the number of seconds 54 | 55 | Arguments: 56 | duration (str): The length of the video. The property value is an ISO 8601 duration. 57 | For example, for a video that is at least one minute long and less than one hour long, 58 | the duration is in the format PT#M#S, in which the letters PT indicate that the value 59 | specifies a period of time, and the letters M and S refer to length in minutes and seconds, 60 | respectively. The # characters preceding the M and S letters are both integers that 61 | specify the number of minutes (or seconds) of the video. For example, a value of 62 | PT15M33S indicates that the video is 15 minutes and 33 seconds long. 63 | 64 | If the video is at least one hour long, the duration is in the format PT#H#M#S, in which the 65 | # preceding the letter H specifies the length of the video in hours and all of the other 66 | details are the same as described above. If the video is at least one day long, 67 | the letters P and T are separated, and the value's format is P#DT#H#M#S. 68 | """ 69 | pattern = re.compile( 70 | r"P" 71 | r"(?:(?P\d+)Y)?" 72 | r"(?:(?P\d+)M)?" 73 | r"(?:(?P\d+)D)?" 74 | r"(?:T" 75 | r"(?:(?P\d+)H)?" 76 | r"(?:(?P\d+)M)?" 77 | r"(?:(?P\d+)S)?" 78 | r")?", 79 | ) 80 | match = re.match(pattern, duration) 81 | if not match: 82 | return 0 83 | data = match.groupdict() 84 | return ( 85 | int(data["years"] or 0) * 365 * 24 * 60 * 60 86 | + int(data["months"] or 0) * 30 * 24 * 60 * 60 87 | + int(data["days"] or 0) * 24 * 60 * 60 88 | + int(data["hours"] or 0) * 60 * 60 89 | + int(data["minutes"] or 0) * 60 90 | + int(data["seconds"] or 0) 91 | ) 92 | 93 | def get_youtube_data(self, *videos: Dict[str, Any]) -> Dict[str, Any]: 94 | """Fetch video data from the youtube API""" 95 | if not self._youtube_api_key: 96 | return {} 97 | video_ids = [video["yt_videoid"] for video in videos] 98 | params = { 99 | "part": "contentDetails", 100 | "id": ",".join(video_ids), 101 | "key": self._youtube_api_key, 102 | "alt": "json", 103 | } 104 | url = f"https://youtube.googleapis.com/youtube/v3/videos?{urllib.parse.urlencode(params)}" 105 | req = urllib.request.Request(url) 106 | req.add_header("Accept", "application/json") 107 | req.add_header("User-Agent", "GitHub Readme YouTube Cards GitHub Action") 108 | with urllib.request.urlopen(req) as response: 109 | data = json.loads(response.read()) 110 | return {video["id"]: video for video in data["items"]} 111 | 112 | def parse_video(self, video: Dict[str, Any]) -> str: 113 | """Parse video entry and return the contents for the readme""" 114 | video_id = video["yt_videoid"] 115 | params = { 116 | "id": video_id, 117 | "title": video["title"], 118 | "lang": self._lang, 119 | "timestamp": int(time.mktime(video["published_parsed"])), 120 | "background_color": self._background_color, 121 | "title_color": self._title_color, 122 | "stats_color": self._stats_color, 123 | "max_title_lines": self._max_title_lines, 124 | "width": self._card_width, 125 | "border_radius": self._border_radius, 126 | } 127 | if video_id in self._youtube_data: 128 | content_details = self._youtube_data[video_id]["contentDetails"] 129 | if self._show_duration: 130 | params["duration"] = self.parse_iso8601_duration(content_details["duration"]) 131 | 132 | dark_params = params | self._theme_context_dark 133 | light_params = params | self._theme_context_light 134 | 135 | if self._output_type == "html": 136 | # translate video to html 137 | html_escaped_title = params["title"].replace('"', """) 138 | if self._theme_context_dark or self._theme_context_light: 139 | return ( 140 | f'\n' 141 | " \n" 142 | f' \n' 143 | f' {html_escaped_title}\n' 144 | " \n" 145 | "" 146 | ) 147 | return f'{html_escaped_title}' 148 | else: 149 | # translate video to standard markdown 150 | backslash_escaped_title = params["title"].replace('"', '\\"') 151 | # if theme context is set, create two versions with theme context specified 152 | if self._theme_context_dark or self._theme_context_light: 153 | return ( 154 | f'[![{params["title"]}]({self._base_url}?{urllib.parse.urlencode(dark_params)} "{backslash_escaped_title}")]({video["link"]}#gh-dark-mode-only)' 155 | f'[![{params["title"]}]({self._base_url}?{urllib.parse.urlencode(light_params)} "{backslash_escaped_title}")]({video["link"]}#gh-light-mode-only)' 156 | ) 157 | return f'[![{params["title"]}]({self._base_url}?{urllib.parse.urlencode(params)} "{backslash_escaped_title}")]({video["link"]})' 158 | 159 | def parse_videos(self) -> str: 160 | """Parse video feed and return the contents for the readme""" 161 | url = "" 162 | if self._playlist_id: 163 | url = f"https://www.youtube.com/feeds/videos.xml?playlist_id={self._playlist_id}" 164 | elif self._channel_id: 165 | url = f"https://www.youtube.com/feeds/videos.xml?channel_id={self._channel_id}" 166 | else: 167 | raise RuntimeError("Either `channel_id` or `playlist_id` must be provided") 168 | feed = feedparser.parse(url) 169 | videos = feed["entries"][: self._max_videos] 170 | self._youtube_data = self.get_youtube_data(*videos) 171 | return "\n".join(map(self.parse_video, videos)) 172 | 173 | 174 | class FileUpdater: 175 | """Update the readme file""" 176 | 177 | @staticmethod 178 | def update(readme_path: str, comment_tag: str, replace_content: str): 179 | """Replace the text between the begin and end tags with the replace content""" 180 | begin_tag = f"" 181 | end_tag = f"" 182 | with open(readme_path, "r") as readme_file: 183 | readme = readme_file.read() 184 | begin_index = readme.find(begin_tag) 185 | end_index = readme.find(end_tag) 186 | if begin_index == -1 or end_index == -1: 187 | raise RuntimeError(f"Could not find tags {begin_tag} and {end_tag} in {readme_path}") 188 | readme = f"{readme[:begin_index + len(begin_tag)]}\n{replace_content}\n{readme[end_index:]}" 189 | with open(readme_path, "w") as readme_file: 190 | readme_file.write(readme) 191 | 192 | 193 | if __name__ == "__main__": 194 | parser = ArgumentParser() 195 | parser.add_argument( 196 | "--channel", 197 | dest="channel_id", 198 | help="YouTube channel ID", 199 | default=None, 200 | ) 201 | parser.add_argument( 202 | "--playlist", 203 | dest="playlist_id", 204 | help="YouTube playlist ID", 205 | default=None, 206 | ) 207 | parser.add_argument( 208 | "--lang", 209 | dest="lang", 210 | help="Language to be used for card description", 211 | default="en", 212 | ) 213 | parser.add_argument( 214 | "--comment-tag-name", 215 | dest="comment_tag_name", 216 | help="Comment tag name", 217 | default="YOUTUBE-CARDS", 218 | ) 219 | parser.add_argument( 220 | "--max-videos", 221 | dest="max_videos", 222 | help="Maximum number of videos to include", 223 | default=6, 224 | type=int, 225 | ) 226 | parser.add_argument( 227 | "--base-url", 228 | dest="base_url", 229 | help="Base URL for the readme", 230 | default="https://ytcards.demolab.com/", 231 | ) 232 | parser.add_argument( 233 | "--card-width", 234 | dest="card_width", 235 | help="Card width for the SVG images", 236 | default=250, 237 | type=int, 238 | ) 239 | parser.add_argument( 240 | "--border-radius", 241 | dest="border_radius", 242 | help="Card border radius for the SVG images", 243 | default=5, 244 | type=int, 245 | ) 246 | parser.add_argument( 247 | "--background-color", 248 | dest="background_color", 249 | help="Background color for the SVG images", 250 | default="#0d1117", 251 | ) 252 | parser.add_argument( 253 | "--title-color", 254 | dest="title_color", 255 | help="Title color for the SVG images", 256 | default="#ffffff", 257 | ) 258 | parser.add_argument( 259 | "--stats-color", 260 | dest="stats_color", 261 | help="Stats color for the SVG images", 262 | default="#dedede", 263 | ) 264 | parser.add_argument( 265 | "--theme-context-light", 266 | dest="theme_context_light", 267 | help="JSON theme for light mode (keys: background_color, title_color, stats_color)", 268 | default="{}", 269 | ) 270 | parser.add_argument( 271 | "--theme-context-dark", 272 | dest="theme_context_dark", 273 | help="JSON theme for dark mode (keys: background_color, title_color, stats_color)", 274 | default="{}", 275 | ) 276 | parser.add_argument( 277 | "--max-title-lines", 278 | dest="max_title_lines", 279 | help="Maximum number of lines for the title", 280 | default=1, 281 | type=int, 282 | ) 283 | parser.add_argument( 284 | "--youtube-api-key", 285 | dest="youtube_api_key", 286 | help="YouTube API key", 287 | default=None, 288 | ) 289 | parser.add_argument( 290 | "--show-duration", 291 | dest="show_duration", 292 | help="Whether to show the duration of the videos", 293 | default="false", 294 | choices=("true", "false"), 295 | ) 296 | parser.add_argument( 297 | "--readme-path", 298 | dest="readme_path", 299 | help="Path to the readme file", 300 | default="README.md", 301 | ) 302 | parser.add_argument( 303 | "--output-only", 304 | dest="output_only", 305 | help="Only output the cards, do not update the readme", 306 | default="false", 307 | choices=("true", "false"), 308 | ) 309 | parser.add_argument( 310 | "--output-type", 311 | dest="output_type", 312 | help="The type of output to be rendered by the action", 313 | default="markdown", 314 | choices=("html", "markdown"), 315 | ) 316 | args = parser.parse_args() 317 | 318 | if args.show_duration == "true" and not args.youtube_api_key: 319 | parser.error("--youtube-api-key is required when --show-duration is true") 320 | 321 | video_parser = VideoParser( 322 | base_url=args.base_url, 323 | channel_id=args.channel_id, 324 | playlist_id=args.playlist_id, 325 | lang=args.lang, 326 | max_videos=args.max_videos, 327 | card_width=args.card_width, 328 | border_radius=args.border_radius, 329 | background_color=args.background_color, 330 | title_color=args.title_color, 331 | stats_color=args.stats_color, 332 | theme_context_light=json.loads(args.theme_context_light), 333 | theme_context_dark=json.loads(args.theme_context_dark), 334 | max_title_lines=args.max_title_lines, 335 | youtube_api_key=args.youtube_api_key, 336 | show_duration=args.show_duration == "true", 337 | output_type=args.output_type, 338 | ) 339 | 340 | video_content = video_parser.parse_videos() 341 | 342 | # output to stdout 343 | print(video_content) 344 | 345 | # update the readme file 346 | if args.output_only == "false": 347 | FileUpdater.update(args.readme_path, args.comment_tag_name, video_content) 348 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "GitHub Readme YouTube Cards" 2 | author: "Jonah Lawrence" 3 | description: "Workflow for displaying recent YouTube videos as SVG cards in your readme" 4 | branding: 5 | icon: "grid" 6 | color: "red" 7 | 8 | inputs: 9 | channel_id: 10 | description: "The channel ID to use for the feed" 11 | required: false 12 | default: "" 13 | playlist_id: 14 | description: "The playlist ID to use for the feed" 15 | required: false 16 | default: "" 17 | lang: 18 | description: "The language you want your cards description to use" 19 | required: false 20 | default: "en" 21 | comment_tag_name: 22 | description: "The name of the comment tag to use for the cards" 23 | required: false 24 | default: "YOUTUBE-CARDS" 25 | max_videos: 26 | description: "The maximum number of videos to display" 27 | required: false 28 | default: "6" 29 | base_url: 30 | description: "The base URL to use for the cards" 31 | required: false 32 | default: "https://ytcards.demolab.com/" 33 | youtube_api_key: 34 | description: "The YouTube API key to use for additional features such a the video duration" 35 | required: false 36 | default: "" 37 | card_width: 38 | description: "The width of the SVG cards" 39 | required: false 40 | default: "250" 41 | border_radius: 42 | description: "The border radius of the SVG cards" 43 | required: false 44 | default: "5" 45 | background_color: 46 | description: "The background color of the SVG cards" 47 | required: false 48 | default: "#0d1117" 49 | title_color: 50 | description: "The color of the title text" 51 | required: false 52 | default: "#ffffff" 53 | stats_color: 54 | description: "The color of the stats text" 55 | required: false 56 | default: "#dedede" 57 | theme_context_light: 58 | description: "JSON theme for light mode (keys: background_color, title_color, stats_color)." 59 | required: false 60 | default: "{}" 61 | theme_context_dark: 62 | description: "JSON theme for dark mode (keys: background_color, title_color, stats_color)" 63 | required: false 64 | default: "{}" 65 | max_title_lines: 66 | description: "The maximum number of lines to use for the title" 67 | required: false 68 | default: "1" 69 | show_duration: 70 | description: "Whether to show the video duration. Requires `youtube_api_key` to be set." 71 | required: false 72 | default: "false" 73 | author_name: 74 | description: "The name of the committer" 75 | required: false 76 | default: "GitHub Actions" 77 | author_email: 78 | description: "The email address of the committer" 79 | required: false 80 | default: "41898282+github-actions[bot]@users.noreply.github.com" 81 | commit_message: 82 | description: "The commit message to use for the commit" 83 | required: false 84 | default: "docs(readme): Update YouTube cards" 85 | readme_path: 86 | description: "The path to the readme file" 87 | required: false 88 | default: "README.md" 89 | output_only: 90 | description: "Whether to return the section markdown as output instead of writing to the file" 91 | required: false 92 | default: "false" 93 | output_type: 94 | description: "The type of output to be rendered by the action ('markdown' or 'html')" 95 | required: false 96 | default: "markdown" 97 | 98 | outputs: 99 | markdown: 100 | description: "The section markdown as output" 101 | value: ${{ steps.generate-readme-update.outputs.markdown }} 102 | committed: 103 | description: "Whether the action has created a commit ('true' or 'false')" 104 | value: ${{ steps.add-and-commit.outputs.committed }} 105 | commit_long_sha: 106 | description: "The full SHA of the commit that has just been created" 107 | value: ${{ steps.add-and-commit.outputs.commit_long_sha }} 108 | commit_sha: 109 | description: "The short 7-character SHA of the commit that has just been created" 110 | value: ${{ steps.add-and-commit.outputs.commit_sha }} 111 | pushed: 112 | description: "Whether the action has pushed to the remote ('true' or 'false')" 113 | value: ${{ steps.add-and-commit.outputs.pushed }} 114 | 115 | runs: 116 | using: "composite" 117 | steps: 118 | - name: Checkout 119 | uses: actions/checkout@v3 120 | 121 | - name: Setup Python 122 | uses: actions/setup-python@v4 123 | with: 124 | python-version: "3.11" 125 | 126 | - name: Install Python dependencies 127 | shell: bash 128 | run: python -m pip install -r ${{ github.action_path }}/requirements-action.txt 129 | 130 | - name: Generate Readme Update 131 | id: "generate-readme-update" 132 | shell: bash 133 | run: | 134 | UPDATE=$(python ${{ github.action_path }}/action.py \ 135 | --channel "${{ inputs.channel_id }}" \ 136 | --playlist "${{ inputs.playlist_id }}" \ 137 | --lang "${{ inputs.lang }}" \ 138 | --comment-tag-name "${{ inputs.comment_tag_name }}" \ 139 | --max-videos ${{ inputs.max_videos }} \ 140 | --base-url "${{ inputs.base_url }}" \ 141 | --card-width ${{ inputs.card_width }} \ 142 | --border-radius ${{ inputs.border_radius }} \ 143 | --background-color "${{ inputs.background_color }}" \ 144 | --title-color "${{ inputs.title_color }}" \ 145 | --stats-color "${{ inputs.stats_color }}" \ 146 | --max-title-lines ${{ inputs.max_title_lines }} \ 147 | --youtube-api-key "${{ inputs.youtube_api_key }}" \ 148 | --show-duration "${{ inputs.show_duration }}" \ 149 | --theme-context-light '${{ inputs.theme_context_light }}' \ 150 | --theme-context-dark '${{ inputs.theme_context_dark }}' \ 151 | --readme-path "${{ inputs.readme_path }}" \ 152 | --output-only "${{ inputs.output_only }}" \ 153 | --output-type "${{ inputs.output_type }}" \ 154 | ) || exit 1 155 | echo "markdown=$(echo $UPDATE)" >> $GITHUB_OUTPUT 156 | 157 | - name: Commit changes 158 | id: "add-and-commit" 159 | uses: EndBug/add-and-commit@v9 160 | with: 161 | message: "${{ inputs.commit_message }}" 162 | author_name: "${{ inputs.author_name }}" 163 | author_email: "${{ inputs.author_email }}" 164 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/github-readme-youtube-cards/ac1b5644f0de583cc59c80f1d2f4f0d0ffd45730/api/__init__.py -------------------------------------------------------------------------------- /api/exceptions.py: -------------------------------------------------------------------------------- 1 | class StatusException(Exception): 2 | status: int 3 | 4 | 5 | class ValidationError(StatusException, ValueError): 6 | """Exception raised when a validation error occurs.""" 7 | 8 | def __init__(self, message, status=400): 9 | super().__init__(message) 10 | self.status = status 11 | -------------------------------------------------------------------------------- /api/index.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from time import gmtime, strftime 3 | 4 | from flask import Flask, render_template, request 5 | from flask.wrappers import Response 6 | 7 | from .utils import ( 8 | data_uri_from_file, 9 | data_uri_from_url, 10 | estimate_duration_width, 11 | fetch_views, 12 | format_relative_time, 13 | is_rtl, 14 | is_rtl_title, 15 | seconds_to_duration, 16 | trim_lines, 17 | ) 18 | from .validate import ( 19 | validate_color, 20 | validate_int, 21 | validate_lang, 22 | validate_string, 23 | validate_video_id, 24 | ) 25 | 26 | app = Flask(__name__) 27 | 28 | # enable jinja2 autoescape for all files including SVG files 29 | app.jinja_options["autoescape"] = True 30 | 31 | 32 | @app.route("/") 33 | def render(): 34 | try: 35 | if "id" not in request.args: 36 | now = datetime.utcnow() 37 | return Response(response=render_template("index.html", now=now)) 38 | video_id = validate_video_id(request, "id") 39 | width = validate_int(request, "width", default=250) 40 | border_radius = validate_int(request, "border_radius", default=5) 41 | background_color = validate_color(request, "background_color", default="#0d1117") 42 | title_color = validate_color(request, "title_color", default="#ffffff") 43 | stats_color = validate_color(request, "stats_color", default="#dedede") 44 | title = validate_string(request, "title", default="") 45 | max_title_lines = validate_int(request, "max_title_lines", default=1) 46 | title_lines = trim_lines(title, (width - 20) // 8, max_title_lines) 47 | publish_timestamp = validate_int(request, "timestamp", default=0) 48 | duration_seconds = validate_int(request, "duration", default=0) 49 | lang = validate_lang(request, "lang", default="en") 50 | thumbnail = data_uri_from_url(f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg") 51 | views = fetch_views(video_id, lang) 52 | diff = format_relative_time(publish_timestamp, lang) if publish_timestamp else "" 53 | stats = f"{views}\u2002•\u2002{diff}" if views and diff else (views or diff) 54 | duration = seconds_to_duration(duration_seconds) 55 | duration_width = estimate_duration_width(duration) 56 | thumbnail_height = round(width * 0.56) 57 | title_line_height = 20 58 | title_height = len(title_lines) * title_line_height 59 | height = thumbnail_height + title_height + 60 60 | response = Response( 61 | response=render_template( 62 | "main.svg", 63 | width=width, 64 | height=height, 65 | title_line_height=title_line_height, 66 | title_height=title_height, 67 | background_color=background_color, 68 | title_color=title_color, 69 | stats_color=stats_color, 70 | title_lines=title_lines, 71 | stats=stats, 72 | thumbnail=thumbnail, 73 | duration=duration, 74 | duration_width=duration_width, 75 | border_radius=border_radius, 76 | rtl=is_rtl(lang), 77 | rtl_title=is_rtl_title("".join(title_lines)), 78 | reduced_bandwidth=True, 79 | ), 80 | status=200, 81 | mimetype="image/svg+xml", 82 | ) 83 | response.headers["Content-Type"] = "image/svg+xml; charset=utf-8" 84 | return response 85 | except Exception as e: 86 | status = getattr(e, "status", 500) 87 | thumbnail = data_uri_from_file("./api/templates/resources/error.jpg") 88 | return Response( 89 | response=render_template( 90 | "error.svg", 91 | message=str(e), 92 | code=status, 93 | thumbnail=thumbnail, 94 | reduced_bandwidth=True, 95 | ), 96 | status=status, 97 | mimetype="image/svg+xml", 98 | ) 99 | 100 | 101 | @app.after_request 102 | def add_header(r): 103 | """Add headers to cache the response no longer than an hour.""" 104 | r.headers["Expires"] = strftime( 105 | "%a, %d %b %Y %H:%M:%S GMT", gmtime(datetime.now().timestamp() + 3600) 106 | ) 107 | r.headers["Last-Modified"] = strftime("%a, %d %b %Y %H:%M:%S GMT", gmtime()) 108 | r.headers["Cache-Control"] = "public, max-age=3600" 109 | return r 110 | -------------------------------------------------------------------------------- /api/locale/ar.yml: -------------------------------------------------------------------------------- 1 | ar: 2 | direction: rtl 3 | view: "1 مشاهدة" 4 | views: "%{number} مشاهدة" 5 | -------------------------------------------------------------------------------- /api/locale/bn.yml: -------------------------------------------------------------------------------- 1 | bn: 2 | view: "1 বার দেখা হয়েছে" 3 | views: "%{number} বার দেখা হয়েছে" 4 | -------------------------------------------------------------------------------- /api/locale/de.yml: -------------------------------------------------------------------------------- 1 | de: 2 | view: "1 Aufruf" 3 | views: "%{number} Aufrufe" 4 | -------------------------------------------------------------------------------- /api/locale/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | view: "1 view" 3 | views: "%{number} views" 4 | -------------------------------------------------------------------------------- /api/locale/es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | view: "1 vista" 3 | views: "%{number} vistas" 4 | -------------------------------------------------------------------------------- /api/locale/fa.yml: -------------------------------------------------------------------------------- 1 | fa: 2 | direction: rtl 3 | view: "1 بازدید" 4 | views: "%{number} بازدید" 5 | -------------------------------------------------------------------------------- /api/locale/fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | view: "1 vue" 3 | views: "%{number} vues" 4 | -------------------------------------------------------------------------------- /api/locale/he.yml: -------------------------------------------------------------------------------- 1 | he: 2 | direction: rtl 3 | view: "1 צפייה" 4 | views: "%{number} צפיות" 5 | -------------------------------------------------------------------------------- /api/locale/hi.yml: -------------------------------------------------------------------------------- 1 | hi: 2 | view: "1 बार देखा गया" 3 | views: "%{number} बार देखा गया" 4 | -------------------------------------------------------------------------------- /api/locale/hu.yml: -------------------------------------------------------------------------------- 1 | hu: 2 | view: "1 megtekintés" 3 | views: "%{number} megtekintés" 4 | -------------------------------------------------------------------------------- /api/locale/id.yml: -------------------------------------------------------------------------------- 1 | id: 2 | view: "1 ditonton" 3 | views: "%{number} ditonton" 4 | -------------------------------------------------------------------------------- /api/locale/it.yml: -------------------------------------------------------------------------------- 1 | it: 2 | view: "1 visualizzazione" 3 | views: "%{number} visualizzazioni" 4 | -------------------------------------------------------------------------------- /api/locale/ja.yml: -------------------------------------------------------------------------------- 1 | ja: 2 | view: "1 回視聴" 3 | views: "%{number} 回視聴" 4 | -------------------------------------------------------------------------------- /api/locale/ko.yml: -------------------------------------------------------------------------------- 1 | ko: 2 | view: "조회수 1회" 3 | views: "조회수 %{number}회" 4 | -------------------------------------------------------------------------------- /api/locale/mi.yml: -------------------------------------------------------------------------------- 1 | mi: 2 | view: "1 tirohanga" 3 | views: "%{number} tirohanga" 4 | -------------------------------------------------------------------------------- /api/locale/pt.yml: -------------------------------------------------------------------------------- 1 | pt: 2 | view: "1 visualização" 3 | views: "%{number} visualizações" 4 | -------------------------------------------------------------------------------- /api/locale/sv.yml: -------------------------------------------------------------------------------- 1 | sv: 2 | view: "1 visning" 3 | views: "%{number} visningar" 4 | -------------------------------------------------------------------------------- /api/locale/ur.yml: -------------------------------------------------------------------------------- 1 | ur: 2 | direction: rtl 3 | view: "1 ملاحظة" 4 | views: "%{number} ملاحظات" 5 | -------------------------------------------------------------------------------- /api/static/css/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | -webkit-box-sizing: border-box; 3 | -moz-box-sizing: border-box; 4 | box-sizing: border-box; 5 | } 6 | 7 | *, 8 | *::before, 9 | *::after { 10 | -webkit-box-sizing: inherit; 11 | -moz-box-sizing: inherit; 12 | box-sizing: inherit; 13 | } 14 | 15 | :root { 16 | --background: #eee; 17 | --card-background: white; 18 | --text: #1a1a1a; 19 | --border: #ccc; 20 | --stroke: #a9a9a9; 21 | --blue-light: #2196f3; 22 | --blue-transparent: #2196f3aa; 23 | --blue-dark: #1e88e5; 24 | --link: #1e88e5; 25 | --link-visited: #b344e2; 26 | --button-outline: black; 27 | --red: #ff6464; 28 | --yellow: #ffee58; 29 | --yellow-light: #fffde7; 30 | } 31 | 32 | [data-theme="dark"] { 33 | --background: #090d13; 34 | --card-background: #0d1117; 35 | --text: #efefef; 36 | --border: #2a2e34; 37 | --stroke: #737373; 38 | --blue-light: #1976d2; 39 | --blue-transparent: #2196f320; 40 | --blue-dark: #1565c0; 41 | --link: #5caff1; 42 | --link-visited: #d57afc; 43 | --button-outline: black; 44 | --red: #ff6464; 45 | --yellow: #a59809; 46 | --yellow-light: #716800; 47 | } 48 | 49 | body { 50 | background: var(--background); 51 | font-family: "Open Sans", sans-serif; 52 | padding-top: 10px; 53 | color: var(--text); 54 | } 55 | 56 | .header-flex { 57 | display: flex; 58 | align-items: center; 59 | justify-content: space-between; 60 | } 61 | 62 | .github { 63 | text-align: center; 64 | } 65 | 66 | .github span { 67 | margin: 0 2px; 68 | } 69 | 70 | .center { 71 | text-align: center; 72 | } 73 | 74 | a { 75 | color: var(--link); 76 | text-decoration: none; 77 | } 78 | 79 | a:visited { 80 | color: var(--link-visited); 81 | } 82 | 83 | a:hover { 84 | filter: brightness(1.2); 85 | } 86 | 87 | .example a:hover { 88 | filter: unset; 89 | } 90 | 91 | .container { 92 | width: 96%; 93 | max-width: 1000px; 94 | margin: 0 auto; 95 | } 96 | 97 | .footer { 98 | margin: 50px 0; 99 | } 100 | 101 | .details { 102 | border: 1px solid var(--border); 103 | border-radius: 5px; 104 | padding: 5px 20px; 105 | margin-bottom: 10px; 106 | background: var(--card-background); 107 | } 108 | 109 | .details > h2 { 110 | margin: 0; 111 | margin-top: 10px; 112 | } 113 | 114 | /* link underline effect */ 115 | 116 | a.underline-hover { 117 | position: relative; 118 | text-decoration: none; 119 | color: var(--text); 120 | margin-top: 2em; 121 | display: inline-flex; 122 | align-items: center; 123 | gap: 0.25em; 124 | } 125 | .underline-hover::before { 126 | content: ""; 127 | position: absolute; 128 | bottom: 0; 129 | right: 0; 130 | width: 0; 131 | height: 1px; 132 | background-color: var(--blue-light); 133 | transition: width 0.4s cubic-bezier(0.25, 1, 0.5, 1); 134 | } 135 | @media (hover: hover) and (pointer: fine) { 136 | .underline-hover:hover::before { 137 | left: 0; 138 | right: auto; 139 | width: 100%; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /api/static/css/toggle-dark.css: -------------------------------------------------------------------------------- 1 | a.darkmode { 2 | position: fixed; 3 | top: 2em; 4 | right: 2em; 5 | color: var(--text); 6 | background: var(--background); 7 | height: 3em; 8 | width: 3em; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | border-radius: 50%; 13 | border: 2px solid var(--border); 14 | box-shadow: 15 | 0 0 3px rgb(0 0 0 / 12%), 16 | 0 1px 2px rgb(0 0 0 / 24%); 17 | transition: 0.2s ease-in box-shadow; 18 | } 19 | 20 | a.darkmode:hover { 21 | box-shadow: 22 | 0 0 6px rgb(0 0 0 / 16%), 23 | 0 3px 6px rgb(0 0 0 / 23%); 24 | } 25 | 26 | @media only screen and (max-width: 600px) { 27 | a.darkmode { 28 | top: unset; 29 | bottom: 1em; 30 | right: 1em; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /api/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/github-readme-youtube-cards/ac1b5644f0de583cc59c80f1d2f4f0d0ffd45730/api/static/images/favicon.png -------------------------------------------------------------------------------- /api/static/js/toggle-dark.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Set a cookie 3 | * @param {string} cname - cookie name 4 | * @param {string} cvalue - cookie value 5 | * @param {number} exdays - number of days to expire 6 | */ 7 | function setCookie(cname, cvalue, exdays) { 8 | const d = new Date(); 9 | d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000); 10 | const expires = `expires=${d.toUTCString()}`; 11 | document.cookie = `${cname}=${cvalue}; ${expires}; path=/`; 12 | } 13 | 14 | /** 15 | * Get a cookie 16 | * @param {string} cname - cookie name 17 | * @returns {string} the cookie's value 18 | */ 19 | function getCookie(name) { 20 | const dc = document.cookie; 21 | const prefix = `${name}=`; 22 | let begin = dc.indexOf(`; ${prefix}`); 23 | /** @type {Number?} */ 24 | let end = null; 25 | if (begin === -1) { 26 | begin = dc.indexOf(prefix); 27 | if (begin !== 0) return null; 28 | } else { 29 | begin += 2; 30 | end = document.cookie.indexOf(";", begin); 31 | if (end === -1) { 32 | end = dc.length; 33 | } 34 | } 35 | return decodeURI(dc.substring(begin + prefix.length, end)); 36 | } 37 | 38 | /** 39 | * Turn on dark mode 40 | */ 41 | function darkmode() { 42 | document.querySelector(".darkmode i").className = "gg-sun"; 43 | setCookie("darkmode", "on", 9999); 44 | document.body.setAttribute("data-theme", "dark"); 45 | } 46 | 47 | /** 48 | * Turn on light mode 49 | */ 50 | function lightmode() { 51 | document.querySelector(".darkmode i").className = "gg-moon"; 52 | setCookie("darkmode", "off", 9999); 53 | document.body.removeAttribute("data-theme"); 54 | } 55 | 56 | /** 57 | * Toggle theme between light and dark 58 | */ 59 | function toggleTheme() { 60 | if (document.body.getAttribute("data-theme") !== "dark") { 61 | /* dark mode on */ 62 | darkmode(); 63 | } else { 64 | /* dark mode off */ 65 | lightmode(); 66 | } 67 | } 68 | 69 | // set the theme based on the cookie 70 | if ( 71 | getCookie("darkmode") === null && 72 | window.matchMedia("(prefers-color-scheme: dark)").matches 73 | ) { 74 | darkmode(); 75 | } 76 | -------------------------------------------------------------------------------- /api/templates/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | {% if reduced_bandwidth == false %} 5 | 6 | {% endif %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | Error {{ code }} 23 | 24 | 25 | 26 | 27 | 29 | {{ message }} 30 | 31 | 32 | -------------------------------------------------------------------------------- /api/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 17 | 18 | GitHub Readme YouTube Cards 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 |

GitHub Readme YouTube Cards

42 | 43 | 44 |
45 | 46 | Sponsor 49 | 50 | View on GitHub 53 | 54 | Star 58 |
59 |
60 | 61 |
62 |
63 |

What is this?

64 |

65 | This is a GitHub Action and dynamic site that generates YouTube cards for a given channel in your 66 | GitHub README. 67 |

68 |
69 | 70 |
71 |

How do I use it?

72 |

73 | Check out the 74 | GitHub Readme 75 | for instructions on how to set up the action and use the cards in your repo or profile page. 76 |

77 |
78 | 79 |
80 |

How do I customize it?

81 |

82 | Check out the 83 | 84 | Advanced Configuration 85 | 86 | section of the GitHub Readme for a list of input options you can use in addition to the 87 | channel_id. 88 | You can also check out the 89 | Wiki 90 | for more information on how to use specific features. 91 |

92 |
93 | 94 |
95 |

What does it look like?

96 |

Here's an example of what the cards will look like in your README:

97 | 98 | 99 | 101 | Automatically Deploy to Fly.io with GitHub Actions 104 | 105 | 106 | 107 | 108 | 110 | Hosting a Python Discord Bot for Free with Fly.io 113 | 114 | 115 | 116 | 117 | 119 | Making a Wordle Clone Discord Bot with Python (Nextcord) 122 | 123 | 124 |
125 | 126 |
127 |

How do I contribute?

128 |

129 | Check out the 130 | 131 | Contributing Guide 132 | 133 | for information on how to install dependencies, run the project, and contribute to the project. 134 |

135 |
136 | 137 | 152 |
153 | 154 | 161 |
162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /api/templates/main.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | {% if reduced_bandwidth == false %} 5 | 6 | {% endif %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | {% if duration != "0:00" %} 18 | 19 | 20 | 21 | 24 | {{ duration }} 25 | 26 | 27 | {% endif %} 28 | 29 | 30 | 32 | {% for line in title_lines %} 33 | {{ line }} 34 | {% endfor %} 35 | 36 | 37 | 38 | 39 | 41 | {{ stats }} 42 | 43 | 44 | -------------------------------------------------------------------------------- /api/templates/resources/error.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/github-readme-youtube-cards/ac1b5644f0de583cc59c80f1d2f4f0d0ffd45730/api/templates/resources/error.jpg -------------------------------------------------------------------------------- /api/utils.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import textwrap 3 | import unicodedata as ud 4 | from datetime import datetime, timedelta 5 | from typing import Optional 6 | from urllib.request import Request, urlopen 7 | 8 | import i18n 9 | import orjson 10 | from babel.dates import format_timedelta 11 | from babel.numbers import format_compact_decimal 12 | 13 | i18n.set("filename_format", "{locale}.{format}") 14 | i18n.set("enable_memoization", True) 15 | i18n.load_path.append("./api/locale") 16 | 17 | 18 | def format_relative_time(timestamp: float, lang: str = "en") -> str: 19 | """Get relative time from unix timestamp (ex. "3 hours ago")""" 20 | delta = timedelta(seconds=timestamp - datetime.now().timestamp()) 21 | return format_timedelta(delta=delta, add_direction=True, locale=lang) 22 | 23 | 24 | def data_uri_from_bytes(*, data: bytes, mime_type: str) -> str: 25 | """Return a base-64 data URI for bytes""" 26 | base64 = codecs.encode(data, "base64").decode("utf-8").replace("\n", "") 27 | return f"data:{mime_type};base64,{base64}" 28 | 29 | 30 | def data_uri_from_url(url: str, *, mime_type: Optional[str] = None) -> str: 31 | """Return base-64 data URI for an image at a given URL. 32 | If not passed, the content type is determined from the response header 33 | if present, otherwise, jpeg is assumed. 34 | 35 | Raises: 36 | HTTPError: If the request fails 37 | """ 38 | with urlopen(url) as response: 39 | data = response.read() 40 | mime_type = mime_type or response.headers["Content-Type"] or "image/jpeg" 41 | assert mime_type is not None 42 | return data_uri_from_bytes(data=data, mime_type=mime_type) 43 | 44 | 45 | def data_uri_from_file(path: str, *, mime_type: Optional[str] = None) -> str: 46 | """Return base-64 data URI for an image at a given file path. 47 | If not passed, the content type is determined from the file extension 48 | if present, otherwise, jpeg is assumed. 49 | """ 50 | with open(path, "rb") as file: 51 | data = file.read() 52 | if mime_type is None: 53 | mime_types = { 54 | ".png": "image/png", 55 | ".jpg": "image/jpeg", 56 | ".jpeg": "image/jpeg", 57 | ".gif": "image/gif", 58 | } 59 | mime_type = mime_types.get(path[path.rfind(".") :].lower(), "image/jpeg") 60 | assert mime_type is not None 61 | return data_uri_from_bytes(data=data, mime_type=mime_type) 62 | 63 | 64 | def trim_lines(text: str, max_length: int, max_lines: int) -> list[str]: 65 | """Trim text to max_length characters per line, adding ellipsis if necessary""" 66 | # use textwrap to split into lines 67 | lines = textwrap.wrap(text, width=max_length) 68 | # if there are more lines than max_lines, trim the last line and add ellipsis 69 | if len(lines) > max_lines: 70 | lines[max_lines - 1] = lines[max_lines - 1][: max_length - 1].strip() + "…" 71 | return lines[:max_lines] 72 | 73 | 74 | def parse_metric_value(value: str) -> int: 75 | """Parse a metric value (ex. "1.2K" => 1200) 76 | 77 | See https://github.com/badges/shields/blob/master/services/text-formatters.js#L56 78 | for the reverse of this function. 79 | """ 80 | suffixes = ["k", "M", "G", "T", "P", "E", "Z", "Y"] 81 | if value[-1] in suffixes: 82 | return int(float(value[:-1]) * 1000 ** (suffixes.index(value[-1]) + 1)) 83 | return int(value) 84 | 85 | 86 | def format_views_value(value: str, lang: str = "en") -> str: 87 | """Format view count, for example "1.2M" => "1.2M views", translations included""" 88 | int_value = parse_metric_value(value) 89 | if int_value == 1: 90 | return i18n.t("view", locale=lang) 91 | formatted_value = format_compact_decimal(int_value, locale=lang, fraction_digits=1) 92 | return i18n.t("views", number=formatted_value, locale=lang) 93 | 94 | 95 | def fetch_views(video_id: str, lang: str = "en") -> str: 96 | """Get number of views for a YouTube video as a formatted metric""" 97 | try: 98 | req = Request(f"https://img.shields.io/youtube/views/{video_id}.json") 99 | req.add_header("User-Agent", "GitHub Readme YouTube Cards") 100 | with urlopen(req) as response: 101 | value = orjson.loads(response.read()).get("value", "") 102 | return format_views_value(value, lang) 103 | except Exception: 104 | return "" 105 | 106 | 107 | def seconds_to_duration(seconds: int) -> str: 108 | """Convert seconds to a formatted duration (ex. "1:23")""" 109 | hours = seconds // 3600 110 | minutes = (seconds % 3600) // 60 111 | seconds = seconds % 60 112 | if hours: 113 | return f"{hours}:{minutes:02d}:{seconds:02d}" 114 | return f"{minutes}:{seconds:02d}" 115 | 116 | 117 | def estimate_duration_width(duration: str) -> int: 118 | """Estimate width of duration string""" 119 | num_digits = len([c for c in duration if c.isdigit()]) 120 | num_colons = len([c for c in duration if c == ":"]) 121 | return num_digits * 7 + num_colons * 5 + 8 122 | 123 | 124 | def is_rtl(lang: str) -> bool: 125 | """Check if language is to be displayed right-to-left""" 126 | return i18n.t("direction", locale=lang, default="ltr") == "rtl" 127 | 128 | 129 | def is_rtl_title(title: str) -> bool: 130 | """Check if a title is to be displayed right-to-left""" 131 | title_bidi_properties = [ud.bidirectional(c) for c in title] 132 | ltr_count = ( 133 | title_bidi_properties.count("L") 134 | + title_bidi_properties.count("LRE") 135 | + title_bidi_properties.count("LRO") 136 | + title_bidi_properties.count("LRI") 137 | ) 138 | rtl_count = ( 139 | title_bidi_properties.count("R") 140 | + title_bidi_properties.count("AL") 141 | + title_bidi_properties.count("RLO") 142 | + title_bidi_properties.count("RLE") 143 | + title_bidi_properties.count("RLI") 144 | ) 145 | return rtl_count > ltr_count 146 | -------------------------------------------------------------------------------- /api/validate.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from babel import Locale, UnknownLocaleError 4 | from flask.wrappers import Request 5 | 6 | from .exceptions import ValidationError 7 | 8 | 9 | def validate_int(req: Request, field: str, default: int = 0) -> int: 10 | """Validate an integer, returns the integer if valid, otherwise the default.""" 11 | value = req.args.get(field, "") 12 | try: 13 | return int(value) 14 | except ValueError: 15 | return default 16 | 17 | 18 | def validate_color(req: Request, field: str, default: str = "#ffffff") -> str: 19 | """Validate a color, returns the color if it's a valid hex code (3, 4, 6, or 8 characters), otherwise the default.""" 20 | value = req.args.get(field, "") 21 | hex_digits = re.sub(r"[^a-fA-F0-9]", "", value) 22 | if len(hex_digits) not in (3, 4, 6, 8): 23 | return default 24 | return f"#{hex_digits}" 25 | 26 | 27 | def validate_video_id(req: Request, field: str) -> str: 28 | """Validate a video ID, returns the video ID if valid. 29 | 30 | Raises: 31 | ValidationError: if the field is not provided or fails the validation regex. 32 | """ 33 | value = req.args.get(field, "") 34 | if value == "": 35 | raise ValidationError(f"Required parameter '{field}' is missing") 36 | if not re.match(r"^[a-zA-Z0-9_-]+$", value): 37 | raise ValidationError(f"'{field}' expects a video ID but got '{value}'") 38 | return value 39 | 40 | 41 | def validate_string(req: Request, field: str, default: str = "") -> str: 42 | """Validate a string, returns the string if valid, otherwise the default.""" 43 | return req.args.get(field, default) 44 | 45 | 46 | def validate_lang(req: Request, field: str, *, default: str = "en") -> str: 47 | """Validate a string with a locale lang, returns the string if the locale 48 | is known by Babel, otherwise the default. 49 | """ 50 | value = req.args.get(field, default) 51 | try: 52 | Locale.parse(value) 53 | except UnknownLocaleError: 54 | value = default 55 | return value 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.build.targets.wheel] 6 | packages = ["api"] 7 | 8 | [project] 9 | name = "github-readme-youtube-cards" 10 | version = "1.7.0" 11 | description = "Workflow for displaying recent YouTube videos as SVG cards in your readme" 12 | authors = [{name = "Jonah Lawrence", email = "jonah@freshidea.com"}] 13 | readme = "README.md" 14 | 15 | [tool.black] 16 | line-length = 100 17 | target-version = ["py310"] 18 | 19 | [tool.isort] 20 | profile = "black" 21 | py_version = 310 22 | line_length = 100 23 | combine_as_imports = true 24 | filter_files = true 25 | 26 | [tool.taskipy.tasks] 27 | black = { cmd = "task lint black", help = "Run black" } 28 | isort = { cmd = "task lint isort", help = "Run isort" } 29 | lint = { cmd = "pre-commit run --all-files", help = "Check all files for linting errors" } 30 | precommit = { cmd = "pre-commit install --install-hooks", help = "Install the precommit hook" } 31 | pyright = { cmd = "pyright", help = "Run pyright" } 32 | 33 | [tool.pyright] 34 | typeCheckingMode = "basic" 35 | include = [ 36 | "api", 37 | "tests", 38 | "*.py", 39 | ] 40 | pythonVersion = "3.11" 41 | # https://github.com/microsoft/pyright/blob/main/docs/configuration.md 42 | reportPropertyTypeMismatch = true 43 | reportDuplicateImport = true 44 | reportUntypedFunctionDecorator = true 45 | reportUntypedClassDecorator = true 46 | reportUntypedBaseClass = true 47 | reportUntypedNamedTuple = true 48 | reportUnknownLambdaType = true 49 | reportInvalidTypeVarUse = true 50 | reportUnnecessaryCast = true 51 | reportSelfClsParameterName = true 52 | reportUnsupportedDunderAll = true 53 | reportUnusedVariable = true 54 | reportUnnecessaryComparison = true 55 | reportUnnecessaryTypeIgnoreComment = true 56 | -------------------------------------------------------------------------------- /requirements-action.txt: -------------------------------------------------------------------------------- 1 | feedparser==6.0.11 -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | tox 2 | pytest 3 | black 4 | pre-commit 5 | taskipy 6 | pyright 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.1.1 2 | gunicorn>=20.1.0 3 | orjson==3.10.18 4 | python-i18n[yaml]==0.3.9 5 | Babel==2.17.0 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/github-readme-youtube-cards/ac1b5644f0de583cc59c80f1d2f4f0d0ffd45730/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask.wrappers import Request 3 | 4 | from api.index import app 5 | 6 | 7 | @pytest.fixture() 8 | def client(): 9 | """A test client for the app""" 10 | return app.test_client() 11 | 12 | 13 | class MockRequest(Request): 14 | """Mock request object for testing""" 15 | 16 | def __init__(self, **kwargs): 17 | self.args = kwargs # type: ignore 18 | 19 | def set_args(self, **kwargs): 20 | self.args = kwargs # type: ignore 21 | 22 | def update_args(self, **kwargs): 23 | self.args.update(kwargs) 24 | 25 | 26 | @pytest.fixture() 27 | def req(): 28 | """Mock request object for testing with no arguments""" 29 | return MockRequest() 30 | -------------------------------------------------------------------------------- /tests/test_action.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from action import FileUpdater, VideoParser 5 | 6 | 7 | def create_video_parser(**kwargs): 8 | return VideoParser( 9 | base_url=kwargs.get("base_url", "https://ytcards.demolab.com/"), 10 | channel_id=kwargs.get("channel_id", "UCipSxT7a3rn81vGLw9lqRkg"), 11 | playlist_id=kwargs.get("playlist_id", None), 12 | lang=kwargs.get("lang", "en"), 13 | max_videos=kwargs.get("max_videos", 6), 14 | card_width=kwargs.get("card_width", 250), 15 | border_radius=kwargs.get("border_radius", 5), 16 | background_color=kwargs.get("background_color", "#0d1117"), 17 | title_color=kwargs.get("title_color", "#ffffff"), 18 | stats_color=kwargs.get("stats_color", "#dedede"), 19 | youtube_api_key=kwargs.get("youtube_api_key", ""), 20 | theme_context_light=kwargs.get("theme_context_light", {}), 21 | theme_context_dark=kwargs.get("theme_context_dark", {}), 22 | max_title_lines=kwargs.get("max_title_lines", 1), 23 | show_duration=kwargs.get("show_duration", False), 24 | output_type=kwargs.get("output_type", "markdown"), 25 | ) 26 | 27 | 28 | def test_parse_iso8601_duration(): 29 | assert VideoParser.parse_iso8601_duration("PT30S") == 30 30 | assert VideoParser.parse_iso8601_duration("PT1M10S") == 70 31 | assert VideoParser.parse_iso8601_duration("PT1H2M10S") == 3730 32 | assert VideoParser.parse_iso8601_duration("P1DT2H10M10S") == 94210 33 | 34 | 35 | def test_parse_videos(): 36 | video_parser = create_video_parser() 37 | videos = video_parser.parse_videos() 38 | 39 | assert len(videos.splitlines()) == 6 40 | 41 | line_regex = r"^\[!\[.*\]\(.* \"(.*)\"\)\]\(.*\)$" 42 | assert all(re.match(line_regex, line) for line in videos.splitlines()) 43 | 44 | assert "https://ytcards.demolab.com/?id=" in videos 45 | assert "title=" in videos 46 | assert "timestamp=" in videos 47 | assert "background_color=" in videos 48 | assert "title_color=" in videos 49 | assert "stats_color=" in videos 50 | assert "width=" in videos 51 | assert "border_radius=" in videos 52 | 53 | 54 | def test_parse_videos_with_theme_context(): 55 | video_parser = create_video_parser( 56 | theme_context_light={ 57 | "background_color": "#ffffff", 58 | "title_color": "#000000", 59 | "stats_color": "#000000", 60 | }, 61 | theme_context_dark={ 62 | "background_color": "#000000", 63 | "title_color": "#ffffff", 64 | "stats_color": "#ffffff", 65 | }, 66 | ) 67 | videos = video_parser.parse_videos() 68 | 69 | assert len(videos.splitlines()) == 6 70 | 71 | line_regex = ( 72 | r"^\[!\[.*\]\(.* \"(.*)\"\)\]\(.*\#gh-dark-mode-only\)" 73 | r"\[!\[.*\]\(.* \"(.*)\"\)\]\(.*\#gh-light-mode-only\)$" 74 | ) 75 | assert all(re.match(line_regex, line) for line in videos.splitlines()) 76 | 77 | 78 | def test_parse_videos_html(): 79 | video_parser = create_video_parser(output_type="html") 80 | videos = video_parser.parse_videos() 81 | 82 | assert len(videos.splitlines()) == 6 83 | 84 | line_regex = r"]*>" 85 | assert all(re.match(line_regex, line) for line in videos.splitlines()) 86 | 87 | 88 | def test_parse_videos_html_theme_context(): 89 | video_parser = create_video_parser( 90 | theme_context_light={ 91 | "background_color": "#ffffff", 92 | "title_color": "#000000", 93 | "stats_color": "#000000", 94 | }, 95 | theme_context_dark={ 96 | "background_color": "#000000", 97 | "title_color": "#ffffff", 98 | "stats_color": "#ffffff", 99 | }, 100 | output_type="html", 101 | ) 102 | videos = video_parser.parse_videos() 103 | 104 | assert len(videos.splitlines()) == 36 105 | 106 | anchor_tag_regex = r"" 107 | picture_tag_regex = r"" 108 | img_tag_regex = f'.*' 109 | assert all(re.match(anchor_tag_regex, line) for line in videos.splitlines()[::6]) 110 | assert all(re.match(picture_tag_regex, line.strip()) for line in videos.splitlines()[1::3]) 111 | assert all(re.match(img_tag_regex, line.strip()) for line in videos.splitlines()[3::6]) 112 | 113 | assert "https://ytcards.demolab.com/?id=" in videos 114 | assert "title=" in videos 115 | assert "timestamp=" in videos 116 | assert "background_color=" in videos 117 | assert "title_color=" in videos 118 | assert "stats_color=" in videos 119 | assert "width=" in videos 120 | assert "border_radius=" in videos 121 | assert "max_title_lines=" in videos 122 | 123 | 124 | def test_playlist_id(): 125 | video_parser = create_video_parser( 126 | channel_id=None, playlist_id="PL9YUC9AZJGFFAErr_ZdK2FV7sklMm2K0J" 127 | ) 128 | videos = video_parser.parse_videos() 129 | 130 | assert len(videos.splitlines()) == 6 131 | 132 | line_regex = r"^\[!\[.*\]\(.* \"(.*)\"\)\]\(.*\)$" 133 | assert all(re.match(line_regex, line) for line in videos.splitlines()) 134 | 135 | 136 | def test_update_file(): 137 | path = "./tests/README.md" 138 | # create a file to test with 139 | with open(path, "w+") as f: 140 | f.write( 141 | "Test Before\n" 142 | "\n" 143 | "\n" 144 | "\n" 145 | "\n" 146 | "Test After\n" 147 | ) 148 | try: 149 | # update the file 150 | FileUpdater.update(path, "YOUTUBE-CARDS", "A\nB\nC") 151 | # read the file and assert the contents 152 | with open(path, "r") as f: 153 | assert f.read() == ( 154 | "Test Before\n" 155 | "\n" 156 | "\n" 157 | "A\n" 158 | "B\n" 159 | "C\n" 160 | "\n" 161 | "\n" 162 | "Test After\n" 163 | ) 164 | finally: 165 | # remove the file 166 | os.remove(path) 167 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import urlencode 3 | 4 | 5 | def test_request_no_id(client): 6 | response = client.get("/") 7 | data = response.data.decode("utf-8") 8 | 9 | assert response.status_code == 200 10 | assert "" in data 11 | 12 | 13 | def test_request_invalid_id(client): 14 | response = client.get("/?id=**********") 15 | data = response.data.decode("utf-8") 16 | 17 | assert response.status_code == 400 18 | assert "'id' expects a video ID but got '**********'" in data 19 | 20 | 21 | def test_request_unknown_id(client): 22 | response = client.get("/?id=abc_123-456") 23 | data = response.data.decode("utf-8") 24 | 25 | assert response.status_code == 404 26 | assert "Not Found" in data 27 | 28 | 29 | def test_request_valid_params(client): 30 | params = { 31 | "id": "dQw4w9WgXcQ", 32 | "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", 33 | "timestamp": "1256450400", 34 | "background_color": "#000000", 35 | "title_color": "#111111", 36 | "stats_color": "#222222", 37 | "width": "500", 38 | "border_radius": "10", 39 | "duration": "211", 40 | "max_title_lines": "1", 41 | } 42 | response = client.get(f"/?{urlencode(params)}") 43 | data = response.data.decode("utf-8") 44 | 45 | assert response.status_code == 200 46 | 47 | # test views 48 | views_regex = re.compile(r"\d+(?:\.\d)?[KMBT]? views") 49 | assert views_regex.search(data) is not None 50 | 51 | # test width 52 | assert 'width="500"' in data 53 | 54 | # test border radius 55 | assert 'rx="10"' in data 56 | 57 | # test background color 58 | assert 'fill="#000000"' in data 59 | 60 | # test title color 61 | assert 'fill="#111111"' in data 62 | 63 | # test stats color 64 | assert 'fill="#222222"' in data 65 | 66 | # test title 67 | assert "Rick Astley - Never Gonna Give You Up (Official Music Video)" in data 68 | 69 | # test duration 70 | assert "3:31" in data 71 | 72 | # test thumbnail 73 | thumbnail_regex = re.compile(r'href="data:image/jpeg;base64,[a-zA-Z0-9+/]+={0,2}"') 74 | assert thumbnail_regex.search(data) is not None 75 | 76 | # test timestamp 77 | timestamp_regex = re.compile(r"\d+ years ago") 78 | assert timestamp_regex.search(data) is not None 79 | 80 | # test direction 81 | assert 'direction="ltr"' in data 82 | 83 | 84 | def test_request_right_to_left(client): 85 | params = { 86 | "id": "dQw4w9WgXcQ", 87 | "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", 88 | "timestamp": "1256450400", 89 | "lang": "he", 90 | } 91 | response = client.get(f"/?{urlencode(params)}") 92 | data = response.data.decode("utf-8") 93 | 94 | # stats group should be 10 pixels from the right 95 | assert "translate(240, 195)" in data 96 | 97 | # test direction 98 | assert 'direction="rtl"' in data 99 | 100 | # test views 101 | views_regex = re.compile(r"\d+(?:\.\d)?[KMBT]?\u200f צפיות") 102 | assert views_regex.search(data) is not None 103 | 104 | 105 | def test_title_right_to_left(client): 106 | params = { 107 | "id": "dQw4w9WgXcQ", 108 | "title": "לורם איפסום דולור סיט אמט קונסקטורר אדיפיסינג אלית,", 109 | "timestamp": "1693000000", 110 | } 111 | response = client.get(f"/?{urlencode(params)}") 112 | data = response.data.decode("utf-8") 113 | 114 | print(data) 115 | 116 | # title should be 12 pixels from the right 117 | assert "translate(238, 150)" in data 118 | 119 | # test direction 120 | assert 'direction="rtl"' in data 121 | 122 | 123 | def test_max_title_lines(client): 124 | params = { 125 | "id": "dQw4w9WgXcQ", 126 | "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", 127 | "timestamp": "1256450400", 128 | "max_title_lines": "2", 129 | } 130 | response = client.get(f"/?{urlencode(params)}") 131 | data = response.data.decode("utf-8") 132 | 133 | assert response.status_code == 200 134 | 135 | assert data.count('') == 2 136 | -------------------------------------------------------------------------------- /tests/test_locales.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import yaml 4 | from babel import Locale, UnknownLocaleError 5 | 6 | 7 | def test_locales_valid(): 8 | """Test that all locales are valid""" 9 | 10 | # get the list of locales 11 | files = os.listdir(os.path.join("api", "locale")) 12 | 13 | # assert that all locales are valid yaml files 14 | assert all(file.endswith(".yml") for file in files) 15 | 16 | # assert that all locales contain valid yaml 17 | for file in files: 18 | with open(os.path.join("api", "locale", file), "r") as f: 19 | contents = f.readlines() 20 | locale = file.split(".")[0] 21 | assert contents[0].strip() == f"{locale}:" 22 | assert yaml.safe_load("".join(contents)) is not None 23 | 24 | # assert that all locales are valid babel locales 25 | locales = [file.split(".yml")[0] for file in files] 26 | for locale in locales: 27 | try: 28 | Locale.parse(locale) 29 | except UnknownLocaleError: 30 | assert False, f"{locale} is not a valid locale" 31 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | from api.utils import ( 5 | data_uri_from_file, 6 | data_uri_from_url, 7 | estimate_duration_width, 8 | fetch_views, 9 | format_relative_time, 10 | format_views_value, 11 | parse_metric_value, 12 | seconds_to_duration, 13 | trim_lines, 14 | ) 15 | 16 | 17 | def test_fetch_views(): 18 | metric_regex = re.compile(r"^\d+(?:\.\d)?[KMBT]? views$") 19 | assert metric_regex.match(fetch_views("dQw4w9WgXcQ")) 20 | 21 | 22 | def test_format_views_value(): 23 | views_regex = re.compile(r"^\d+(?:\.\d)?[KMBT]? views$") 24 | assert format_views_value("1") == "1 view" 25 | assert views_regex.match(format_views_value("100")) 26 | assert views_regex.match(format_views_value("1k")) 27 | assert views_regex.match(format_views_value("1.5k")) 28 | assert views_regex.match(format_views_value("2M")) 29 | assert views_regex.match(format_views_value("1.5G")) 30 | 31 | 32 | def test_format_views_value_i18n(): 33 | views_regex = re.compile(r"^\d+(?:\,\d)?(?:\u00a0(?:k|M|Md|B))? vues$") 34 | assert format_views_value("1", "fr") == "1 vue" 35 | assert views_regex.match(format_views_value("100", "fr")) 36 | assert views_regex.match(format_views_value("1k", "fr")) 37 | assert views_regex.match(format_views_value("1.5k", "fr")) 38 | assert views_regex.match(format_views_value("2M", "fr")) 39 | assert views_regex.match(format_views_value("1.5G", "fr")) 40 | 41 | 42 | def test_format_relative_time(): 43 | # values are handled by Babel, so we just test that the function is called successfully 44 | assert format_relative_time(datetime.now().timestamp() - 3600) == "1 hour ago" 45 | 46 | 47 | def test_format_relative_time_i18n(): 48 | # values are handled by Babel, so we just test that the function is called successfully 49 | assert format_relative_time(datetime.now().timestamp() - 3600, "fr") == "il y a 1 heure" 50 | 51 | 52 | def test_parse_metric_value(): 53 | assert parse_metric_value("1") == 1 54 | assert parse_metric_value("100") == 100 55 | assert parse_metric_value("1k") == 1000 56 | assert parse_metric_value("1.5k") == 1500 57 | assert parse_metric_value("1.5M") == 1500000 58 | assert parse_metric_value("1.5G") == 1500000000 59 | 60 | 61 | def test_data_uri_from_url_and_file(): 62 | error_png = data_uri_from_file("./api/templates/resources/error.jpg") 63 | assert error_png.startswith("data:image/jpeg;base64,/9j/2wBDAAQDAwQDA") 64 | 65 | thumbnail_png = data_uri_from_url("https://i.ytimg.com/vi/FuenvuekLqc/mqdefault.jpg") 66 | assert thumbnail_png.startswith("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/") 67 | 68 | 69 | def test_trim_lines(): 70 | assert trim_lines("abcdefghijklmnopqrstuvwxyz", 100, 1) == ["abcdefghijklmnopqrstuvwxyz"] 71 | assert trim_lines("abcdefghijklmnopqrstuvwxyz", 10, 1) == ["abcdefghi…"] 72 | assert trim_lines("abcdefghij", 10, 1) == ["abcdefghij"] 73 | assert trim_lines("abcdefghijklmnopqrstuvwxyz", 10, 2) == ["abcdefghij", "klmnopqrs…"] 74 | assert trim_lines("abcdefghij", 10, 2) == ["abcdefghij"] 75 | 76 | 77 | def test_seconds_to_duration(): 78 | assert seconds_to_duration(0) == "0:00" 79 | assert seconds_to_duration(1) == "0:01" 80 | assert seconds_to_duration(60) == "1:00" 81 | assert seconds_to_duration(61) == "1:01" 82 | assert seconds_to_duration(3600) == "1:00:00" 83 | assert seconds_to_duration(3601) == "1:00:01" 84 | assert seconds_to_duration(3661) == "1:01:01" 85 | 86 | 87 | def test_estimate_duration_width(): 88 | assert estimate_duration_width("1:00") == 34 89 | assert estimate_duration_width("10:00") == 41 90 | assert estimate_duration_width("1:00:00") == 53 91 | assert estimate_duration_width("10:00:00") == 60 92 | -------------------------------------------------------------------------------- /tests/test_validate.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from api.validate import ( 4 | ValidationError, 5 | validate_color, 6 | validate_int, 7 | validate_string, 8 | validate_video_id, 9 | ) 10 | 11 | 12 | def test_validate_int(req): 13 | # missing field 14 | assert validate_int(req, "width", default=250) == 250 15 | # invalid field 16 | req.set_args(width="abc") 17 | assert validate_int(req, "width", default=250) == 250 18 | # valid field 19 | req.set_args(width="100") 20 | assert validate_int(req, "width", default=250) == 100 21 | 22 | 23 | def test_validate_color(req): 24 | # missing field 25 | assert validate_color(req, "background_color", default="#0d1117") == "#0d1117" 26 | # invalid field characters 27 | req.set_args(background_color="#fghijk") 28 | assert validate_color(req, "background_color", default="#0d1117") == "#0d1117" 29 | # invalid field length 30 | req.set_args(background_color="#012345678") 31 | assert validate_color(req, "background_color", default="#0d1117") == "#0d1117" 32 | # valid field - 6 characters 33 | req.set_args(background_color="#ffffff") 34 | assert validate_color(req, "background_color", default="#0d1117") == "#ffffff" 35 | # valid field - 8 characters 36 | req.set_args(background_color="#01234567") 37 | assert validate_color(req, "background_color", default="#0d1117") == "#01234567" 38 | # valid field - 4 characters 39 | req.set_args(background_color="#89ab") 40 | assert validate_color(req, "background_color", default="#0d1117") == "#89ab" 41 | # valid field - 3 characters 42 | req.set_args(background_color="#cde") 43 | assert validate_color(req, "background_color", default="#0d1117") == "#cde" 44 | 45 | 46 | def test_validate_video_id(req): 47 | # missing field 48 | with pytest.raises(ValidationError): 49 | validate_video_id(req, "id") 50 | # invalid field 51 | req.set_args(id="*********") 52 | with pytest.raises(ValidationError): 53 | validate_video_id(req, "id") 54 | # valid field 55 | req.set_args(id="abc_123-456") 56 | assert validate_video_id(req, "id") == "abc_123-456" 57 | 58 | 59 | def test_validate_string(req): 60 | # missing field 61 | assert validate_string(req, "text", default="Hello, world!") == "Hello, world!" 62 | # valid field 63 | req.set_args(text="Hello, world!") 64 | assert validate_string(req, "text", default="Hello, world!") == "Hello, world!" 65 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = python3.11 3 | isolated_build = True 4 | 5 | [testenv] 6 | deps = 7 | -rrequirements.txt 8 | -rrequirements-dev.txt 9 | -rrequirements-action.txt 10 | commands = pytest tests -s -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/api/index" }] 3 | } 4 | --------------------------------------------------------------------------------