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 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
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 |
239 |
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' \n'
144 | " \n"
145 | ""
146 | )
147 | return f''
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 |
--------------------------------------------------------------------------------
/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 |
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:
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 |