├── .devcontainer
├── Dockerfile
└── devcontainer.json
├── .dockerignore
├── .github
├── CODEOWNERS
├── FUNDING.yml
└── workflows
│ ├── pr.yml
│ └── tssvibelounge.yml
├── .gitignore
├── Dockerfile
├── README.md
├── app.py
├── assets
├── bin
│ └── geckodriver
├── fonts
│ ├── impact.ttf
│ ├── verdana.ttf
│ ├── verdanab.ttf
│ ├── verdanai.ttf
│ └── verdanaz.ttf
├── images
│ ├── NicePng_evil-png_840970.png
│ ├── NicePng_masha-and-the-bear_1292475.png
│ ├── NicePng_meme-png_4338371.png
│ ├── NicePng_roblox-girl-png_3028421.png
│ ├── NicePng_spongegar-png_328488.png
│ ├── NicePng_twitch-emotes-png_4412701.png
│ ├── NicePng_tyler-the-creator-png_1696064.png
│ ├── PngItem_1241663.png
│ ├── Whatshowhasnolikablecharacters.png
│ ├── centaur.png
│ ├── chicken.png
│ ├── dog2.png
│ ├── duck.png
│ ├── fairy.png
│ ├── frog.png
│ ├── gabe.png
│ ├── ghost-dress.png
│ ├── ghost.png
│ ├── harambe.png
│ ├── kermifire.png
│ ├── kermitgun.png
│ ├── kermitowel.png
│ ├── kermitsuit.png
│ ├── lemonman.png
│ ├── mermaid-g2d706be1f_1920.png
│ ├── monster.png
│ ├── people
│ │ └── woman001.jpg
│ ├── priest.png
│ ├── python-reddit-youtube-bot-tutorial.png
│ ├── robot-g55cc67956_1920.png
│ ├── shia.png
│ ├── shibi.png
│ ├── shockface.png
│ ├── successkid.png
│ ├── woman-g618a29d8c_1920.png
│ ├── woman-g6cfce431d_1920.png
│ ├── woman-g8d743d166_1920.png
│ ├── woman.jpg
│ ├── xm5gsv_thumbnail.png
│ ├── xn8xzu.png
│ ├── young-woman-g4459282f6_1920.png
│ └── young-woman-gaa93b4883_1920.png
├── intro.mp4
├── intro_welcome.mp4
├── intro_welcome_crop.mp4
├── music
│ ├── [Non-Copyrighted Music] Chill Jazzy Lofi Hip Hop (Royalty Free) Jazz Hop Music [9Ojb8t3T2Ng].mp3
│ └── [Non-Copyrighted Music] Chill Jazzy Lofi Hip-Hop (Royalty Free) Jazz Hop Music [IygKZNWPM_0].mp3
├── newscaster.mp4
├── newscaster.png
├── newscaster02.mp4
├── particles.mp4
└── scary
│ └── transition
│ └── lightning.mp4
├── balcon.exe
├── comment_templates
├── dark_reddit_mockup
│ └── index.html
├── example
│ └── index.html
├── light_reddit_mockup
│ └── index.html
└── old_reddit_mockup
│ └── index.html
├── comments
├── cookie-dark-mode.json
├── cookie-light-mode.json
└── screenshot.py
├── config
├── auth-env.py
├── auth-example.py
└── settings.py
├── csvmgr.py
├── data.csv
├── dependencies
└── scripts
│ └── postCreateCommand.sh
├── docker-compose.yml
├── docs
├── ERRORS.md
├── FEATURES.md
├── OrderOfEvents.md
├── edge-tts-voices.md
└── notes.md
├── libsamplerate.dll
├── policy.xml
├── publish
├── login.py
├── upload.py
└── youtube.py
├── pyproject.toml
├── reddit
└── reddit.py
├── referral.txt
├── refresh_token.py
├── requirements-dev.txt
├── requirements.txt
├── speech
├── speech.py
├── streamlabs_polly.py
└── tiktok.py
├── static.mp4
├── test_upload.py
├── thumbnail
├── keywords.py
├── lexica.py
└── thumbnail.py
├── utils
├── base64_encoding.py
├── common.py
└── speed_test.py
└── video_generation
└── video.py
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11-bullseye
2 |
3 | ENV DEBIAN_FRONTEND=noninteractive
4 | ENV PYTHONDONTWRITEBYTECODE 1
5 | ENV PYTHONUNBUFFERED 1
6 | ENV PATH="/home/vscode/.local/bin:/scripts:$PATH"
7 |
8 | ARG USERNAME=vscode
9 | ARG USER_UID=1000
10 | ARG USER_GID=$USER_UID
11 |
12 | #RUN mkdir -p /scripts
13 | COPY ./dependencies /
14 | RUN chmod -R +x /scripts
15 |
16 | RUN groupadd --gid $USER_GID $USERNAME && \
17 | useradd --uid $USER_UID --gid $USER_GID -m $USERNAME && \
18 | apt-get update && \
19 | apt-get install -y sudo vim ghostscript && \
20 | echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME && \
21 | chmod 0440 /etc/sudoers.d/$USERNAME && \
22 | chmod -R +x /scripts
23 |
24 | RUN apt-get update -y && \
25 | apt-get install -y --no-install-recommends build-essential
26 |
27 | RUN mkdir -p /home/vscode/.local/bin && \
28 | curl -s https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /home/vscode/.local/bin/yt-dlp && \
29 | chmod +x /home/vscode/.local/bin/yt-dlp && \
30 | chown -R vscode /home/vscode/.local
31 |
32 | USER $USERNAME
33 |
34 | ENTRYPOINT ["/bin/bash"]
35 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json.
2 | {
3 | "name": "redyoubot",
4 | "build": {
5 | "dockerfile": "Dockerfile",
6 | "context": "..",
7 | "args": {
8 | }
9 | },
10 |
11 | "customizations": {
12 | "vscode": {
13 | // Set *default* container specific settings.json values on container create.
14 | "settings":{
15 | "python.pythonPath": "/usr/local/bin/python",
16 | "python.languageServer": "Pylance",
17 | "python.globalModuleInstallation": false,
18 | "python.logging.level": "debug",
19 | "python.venvPath": "~/.pyenv",
20 | "python.terminal.activateEnvInCurrentTerminal": true,
21 | "python.analysis.autoSearchPaths": true,
22 | "python.analysis.autoImportCompletions": true,
23 | "python.analysis.typeCheckingMode": "strict",
24 | "python.formatting.provider": "black",
25 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black",
26 | "python.linting.enabled": true,
27 | "python.linting.lintOnSave": true,
28 | "python.linting.banditEnabled": true,
29 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
30 | "python.linting.flake8Enabled": true,
31 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
32 | "python.linting.mypyEnabled": true,
33 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
34 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
35 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
36 | "python.testing.autoTestDiscoverOnSaveEnabled": true,
37 | "python.testing.pytestEnabled": true,
38 | "python.testing.unittestEnabled": true,
39 | "terminal.integrated.shellIntegration.enabled": true,
40 | "terminal.integrated.copyOnSelection": true,
41 | "terminal.integrated.gpuAcceleration": "auto",
42 | "terminal.integrated.useWslProfiles": true,
43 | "terminal.integrated.defaultProfile.linux":"bash"
44 | }
45 | },
46 |
47 | // Add the IDs of extensions you want installed when the container is created.
48 | "extensions": [
49 | "ms-python.python",
50 | "ms-python.vscode-pylance"
51 | ]
52 | },
53 |
54 |
55 | "features": {
56 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1.2.1": {},
57 | "ghcr.io/devcontainers/features/git:1.1.5": {}
58 | },
59 |
60 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
61 | // "forwardPorts": [],
62 |
63 | // Use 'postCreateCommand' to run commands after the container is created.
64 | "postCreateCommand": "/scripts/postCreateCommand.sh",
65 |
66 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
67 | "remoteUser": "vscode"
68 | }
69 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | final/*
2 | config/auth.py
3 | .env
4 | client_secret.json
5 | cookies.json
6 | credentials.storage
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @alexlaverty @nathonfowlie
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: alaverty
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
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 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: ['https://www.buymeacoffee.com/alexlaverty', 'https://alexlaverty.github.io/donate']
14 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: PR Build
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 | container: alexlaverty/ttsvibelounge:1.0.8
9 | steps:
10 |
11 | - name: checkout repo content
12 | uses: actions/checkout@v3
13 |
14 | - name: Run ttsvibelounge Script
15 | env:
16 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
17 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
18 | CREDENTIALS_STORAGE: ${{ secrets.CREDENTIALS_STORAGE }}
19 | PRAW_CLIENT_ID: ${{ secrets.PRAW_CLIENT_ID }}
20 | PRAW_CLIENT_SECRET: ${{ secrets.PRAW_CLIENT_SECRET }}
21 | PRAW_USER_AGENT: ${{ secrets.PRAW_USER_AGENT }}
22 | PRAW_USERNAME: ${{ secrets.PRAW_USERNAME }}
23 | PRAW_PASSWORD: ${{ secrets.PRAW_PASSWORD }}
24 | RUMBLE_PASSWORD: ${{ secrets.RUMBLE_PASSWORD }}
25 | RUMBLE_USERNAME: ${{ secrets.RUMBLE_USERNAME }}
26 | YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
27 | GH_TOKEN: ${{ secrets.GH_TOKEN }}
28 | run: |
29 | echo $PWD
30 | echo ${{ github.workspace }}
31 | echo $GITHUB_WORKSPACE
32 | echo $YOUTUBE_CLIENT_SECRET > client_secret.json
33 | echo $CREDENTIALS_STORAGE > credentials.storage
34 | cp config/auth-env.py config/auth.py
35 | playwright install
36 | python3 app.py --total-posts 1 \
37 | --video-length 60 \
38 | --enable-background \
39 | --background-directory /app/assets/backgrounds
40 | rm -f client_secret.json
41 | rm -f credentials.storage
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/.github/workflows/tssvibelounge.yml:
--------------------------------------------------------------------------------
1 | name: TTSVibeLounge
2 | on:
3 | # schedule:
4 | # - cron: '0 */6 * * *'
5 | push:
6 | branches:
7 | - main
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | container: alexlaverty/ttsvibelounge:1.0.8
12 | steps:
13 |
14 | # # Git version in container must match git version on ubuntu server
15 | # - name: get git
16 | # run: |
17 | # apt update -y
18 | # apt install -y libz-dev libssl-dev libcurl4-gnutls-dev libexpat1-dev gettext cmake gcc
19 | # wget https://github.com/git/git/archive/refs/tags/v2.38.1.tar.gz
20 | # tar -xvf v2.38.1.tar.gz
21 | # cd git-2.38.1
22 | # make prefix=/usr/local all
23 | # make prefix=/usr/local install
24 | # git --version
25 | # gh secret set CREDENTIALS_STORAGE_REFRESH < credentials.storage
26 |
27 | - name: checkout repo content
28 | uses: actions/checkout@v3
29 |
30 | - name: Run ttsvibelounge Script
31 | env:
32 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
33 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
34 | CREDENTIALS_STORAGE: ${{ secrets.CREDENTIALS_STORAGE }}
35 | PRAW_CLIENT_ID: ${{ secrets.PRAW_CLIENT_ID }}
36 | PRAW_CLIENT_SECRET: ${{ secrets.PRAW_CLIENT_SECRET }}
37 | PRAW_USER_AGENT: ${{ secrets.PRAW_USER_AGENT }}
38 | PRAW_USERNAME: ${{ secrets.PRAW_USERNAME }}
39 | PRAW_PASSWORD: ${{ secrets.PRAW_PASSWORD }}
40 | RUMBLE_PASSWORD: ${{ secrets.RUMBLE_PASSWORD }}
41 | RUMBLE_USERNAME: ${{ secrets.RUMBLE_USERNAME }}
42 | YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
43 | GH_TOKEN: ${{ secrets.GH_TOKEN }}
44 | run: |
45 | echo $PWD
46 | echo ${{ github.workspace }}
47 | echo $GITHUB_WORKSPACE
48 | echo $YOUTUBE_CLIENT_SECRET > client_secret.json
49 | echo $CREDENTIALS_STORAGE > credentials.storage
50 | cp config/auth-env.py config/auth.py
51 | playwright install
52 | python3 app.py --total-posts 1 \
53 | --enable-upload \
54 | --enable-background \
55 | --background-directory /app/assets/backgrounds
56 | python3 refresh_token.py
57 | rm -f client_secret.json
58 | rm -f credentials.storage
59 |
60 |
61 | - name: check for changes
62 | run: |
63 | git config --global --add safe.directory $(realpath .)
64 | if git diff --exit-code data.csv; then
65 | echo changes_exist=false
66 | echo "changes_exist=false" >> $GITHUB_ENV
67 | else
68 | echo changes_exist=true
69 | echo "changes_exist=true" >> $GITHUB_ENV
70 | fi
71 |
72 | - name: Push updates
73 | if: env.changes_exist == 'true'
74 | run: |
75 | git config --local user.email "action@github.com"
76 | git config --local user.name "GitHub Action"
77 | git add data.csv
78 | git commit -m "TTSVibeLounge Scheduled Update" -a
79 | git push -f
80 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | config.yml
2 | config.py
3 | __pycache__/
4 | audio/
5 | backgrounds/
6 | final/
7 | thumbnails/
8 | *.log
9 | *.mp3
10 | backup/
11 | .vscode/
12 | client_secret.json
13 | cookies*.json
14 | credentials.storage
15 | videos/
16 | config/auth.py
17 | .env
18 |
19 | obtain_refresh_token.py
20 | setPython.cmd
21 | data.csv
22 |
23 | **/__pycache__/
24 | assets/work_dir/
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu
2 |
3 | ENV DEBIAN_FRONTEND=noninteractive
4 | # ENV FIREFOX_VER 87.0
5 | # ENV GECKODRIVER_VER v0.30.0
6 |
7 | COPY assets/fonts /usr/share/fonts/truetype/ttsvibelounge
8 |
9 | #COPY assets/bin/geckodriver /usr/bin/geckodriver
10 |
11 | RUN apt update -y && \
12 | apt install -y \
13 | curl \
14 | imagemagick \
15 | libmagick++-dev \
16 | python3-pip \
17 | python3 \
18 | #git \
19 | ttf-mscorefonts-installer \
20 | vim && \
21 | fc-cache -f
22 |
23 | # Install specific version of git to satisfy github workflow action requirements
24 | run apt install -y libz-dev libssl-dev libcurl4-gnutls-dev libexpat1-dev gettext cmake gcc && \
25 | wget https://github.com/git/git/archive/refs/tags/v2.38.1.tar.gz && \
26 | tar -xvf v2.38.1.tar.gz && \
27 | cd git-2.38.1 && \
28 | make prefix=/usr/local all && \
29 | make prefix=/usr/local install
30 |
31 | COPY . /app
32 |
33 | COPY policy.xml /etc/ImageMagick-6/policy.xml
34 |
35 | COPY config/auth-env.py /app/config/auth.py
36 |
37 | WORKDIR /app
38 |
39 | RUN pip install -r requirements.txt
40 |
41 | RUN playwright install-deps && playwright install
42 |
43 | CMD ["python3", "/app/app.py"]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Automated Reddit to Youtube Bot
2 |
3 |
4 | * [Description](#description)
5 | * [Example Videos](#example-videos)
6 | * [Reddit Youtube Channels](#reddit-youtube-channels)
7 | * [Install Prerequisite Components](#install-prerequisite-components)
8 | * [Git clone repository](#git-clone-repository)
9 | * [Generate Reddit Tokens](#generate-reddit-tokens)
10 | * [Copy auth config](#copy-auth-config)
11 | * [Python Pip Install Dependencies](#python-pip-install-dependencies)
12 | * [Install Playwright](#install-playwright)
13 | * [Run Python Script](#run-python-script)
14 | * [Generate a Video for a Specific Post](#generate-a-video-for-a-specific-post)
15 | * [Generate Only Thumbnails](#generate-only-thumbnails)
16 | * [Enable a Newscaster](#enable-a-newscaster)
17 | * [Settings.py File](#settings.py-file)
18 |
19 |
23 |
24 |
25 | ## Description
26 |
27 | Scrape posts from Reddit and automatically generate Youtube Videos and Thumbnails
28 |
29 | ## Example Videos
30 |
31 | Checkout my Youtube Channel for example videos made by this repo :
32 |
33 | [What crime are you okay with people committing?](https://youtu.be/gOX1Uhxba-g)
34 | [](https://youtu.be/gOX1Uhxba-g)
35 |
36 | [What show has no likable characters?](https://youtu.be/xAaPbntOVb8)
37 | [](https://youtu.be/xAaPbntOVb8)
38 |
39 | ## Reddit Youtube Channels
40 |
41 | Youtube Channels generated using this repo :
42 | * [TTSVibeLounge](https://www.youtube.com/@ttsvibelounge/videos)
43 | * [Aussie Banter Reddit](https://www.youtube.com/@AussieBanterReddit)
44 |
45 | If your Youtube Channel is generated using this repository and you would like it listed above please comment on this issue https://github.com/alexlaverty/python-reddit-youtube-bot/issues/91 with your youtube channel url and channel name and mention you'd like it listed in the main README file.
46 |
47 |
48 | # Quickstart Guide
49 |
50 | # Windows
51 |
52 |
53 | [Watch the Python Reddit Youtube Bot Tutorial Video :](https://youtu.be/LaFFU9EskfA)
54 | [](https://youtu.be/LaFFU9EskfA)
55 |
56 | ## Install Prerequisite Components
57 |
58 | Install these prerequisite components first :
59 |
60 | * Git - https://git-scm.com/download/win
61 |
62 | * Python 3.10 - https://www.python.org/ftp/python/3.10.0/python-3.10.0-amd64.exe
63 |
64 | * Microsoft C++ Build Tools - https://visualstudio.microsoft.com/visual-cpp-build-tools/
65 |
66 | * ImageMagick - https://imagemagick.org/script/download.php#windows
67 |
68 | ## Git clone repository
69 |
70 | ```
71 | git clone git@github.com:alexlaverty/python-reddit-youtube-bot.git
72 | cd python-reddit-youtube-bot
73 | ```
74 |
75 | ## Generate Reddit Tokens
76 |
77 | Generate Reddit PRAW Tokens - https://www.reddit.com/prefs/apps/
78 |
79 | ## Copy auth config
80 |
81 | Create a copy of the auth-example.py file and name it auth.py :
82 |
83 | ```
84 | copy config/auth-example.py config/auth.py
85 | ```
86 |
87 | Update the `auth.py` file to contain the Reddit Auth tokens you generated in the previous step.
88 |
89 | ## Python Pip Install Dependencies
90 |
91 | ```
92 | pip install -r requirements.txt
93 | ```
94 |
95 | ## Install Playwright
96 |
97 | Install and configure playwright by running :
98 |
99 | ```
100 | playwright install
101 | ```
102 |
103 | ## Run Python Script
104 |
105 | Run the python script :
106 |
107 | ```
108 | python app.py
109 | ```
110 |
111 | when it completes the video will be generated into the `videos` folder and will be named `final.mp4`
112 |
113 | # Downloading video backgrounds using yt-dlp :
114 |
115 | If you want to add a video background then install yt-dlp :
116 |
117 | [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe)
118 |
119 | then create a `backgrounds` folder and run the following command :
120 |
121 | ```
122 | mkdir -p assets/backgrounds
123 | cd assets/backgrounds
124 | yt-dlp --playlist-items 1:10 -f 22 --output "%(uploader)s_%(id)s.%(ext)s" https://www.youtube.com/playlist?list=PLGmxyVGSCDKvmLInHxJ9VdiwEb82Lxd2E
125 | ```
126 |
127 | # Help
128 |
129 | You can view available parameters by passing in `--help` :
130 |
131 | ```
132 | python app.py --help
133 |
134 | ##### YOUTUBE REDDIT BOT #####
135 | usage: app.py [-h] [-l VIDEO_LENGTH] [-o] [-s] [-t] [-u URL]
136 |
137 | options:
138 | -h, --help show this help message and exit
139 | -l VIDEO_LENGTH, --video-length VIDEO_LENGTH
140 | Set how long you want the video to be
141 | -o, --disable-overlay
142 | Disable video overlay
143 | -s, --story-mode Generate video for post title and selftext only, disables user comments
144 | -t, --thumbnail-only Generate thumbnail image only
145 | -u URL, --url URL Specify Reddit post url, seperate with a comma for multiple posts.
146 | ```
147 |
148 | ## Generate a Video for a Specific Post
149 |
150 | or if you want to generate a video for a specific reddit post you can specify it via the `--url` param :
151 |
152 | ```
153 | python app.py --url https://www.reddit.com/r/AskReddit/comments/hvsxty/which_legendary_reddit_post_comment_can_you_still/
154 | ```
155 |
156 | or you can do multiple url's by seperating with a comma, ie :
157 |
158 | ```
159 | python app.py --url https://www.reddit.com/r/post1,https://www.reddit.com/r/post2,https://www.reddit.com/r/post3
160 | ```
161 |
162 | ## Generate Only Thumbnails
163 |
164 | if you want to generate only thumbnails you can specify `--thumbnail-only` mode, this will skip video compilation process :
165 |
166 | ```
167 | python app.py --thumbnail-only
168 | ```
169 |
170 | ## Enable a Newscaster
171 |
172 | If you want to enable a Newscaster, edit settings.py and set :
173 |
174 | ```
175 | enable_newscaster = True
176 | ```
177 |
178 | 
179 |
180 | If the newcaster video has a green screen you can remove it with the following settings,
181 | use an eye dropper to get the RGB colour of the greenscreen and set it to have it removed :
182 |
183 | ```
184 | newscaster_remove_greenscreen = True
185 | newscaster_greenscreen_color = [1, 255, 17] # Enter the Green Screen RGB Colour
186 | newscaster_greenscreen_remove_threshold = 100
187 | ```
188 |
189 | ## Settings.py File
190 |
191 | Theres quite a few options you can customise in the `settings.py` file :
192 |
193 | Specify which subreddits you want to scrape :
194 |
195 | ```
196 | subreddits = [
197 | "AmItheAsshole",
198 | "antiwork",
199 | "AskMen",
200 | "ChoosingBeggars",
201 | "hatemyjob",
202 | "NoStupidQuestions",
203 | "pettyrevenge",
204 | "Showerthoughts",
205 | "TooAfraidToAsk",
206 | "TwoXChromosomes",
207 | "unpopularopinion",
208 | "confessions",
209 | "confession"
210 | ]
211 | ```
212 |
213 | Subreddits to exclude :
214 |
215 | ```
216 | subreddits_excluded = [
217 | "r/CFB",
218 | ]
219 | ```
220 |
221 | Filter out reddit posts via specified keywords
222 |
223 | ```
224 | banned_keywords =["my", "nasty", "keywords"]
225 | ```
226 |
227 | Change the Text to Speech engine you want to use, note AWS Polly requires and AWS account and auth tokens and can incur costs :
228 |
229 | Supports Speech Engines :
230 |
231 | * [AWS Polly](https://aws.amazon.com/polly/)
232 | * [Balcon](http://www.cross-plus-a.com/bconsole.htm)
233 | * Python [gtts](https://gtts.readthedocs.io/en/latest/)
234 |
235 | ```
236 | # choices "polly","balcon","gtts"
237 | voice_engine = "polly"
238 | ```
239 |
240 | Total number of reddit Videos to generate
241 |
242 | ```
243 | total_posts_to_process = 5
244 | ```
245 |
246 | The next settings are to automatically filter out posts
247 |
248 | Skip reddit posts that less than this amount of updates
249 |
250 | ```
251 | minimum_submission_score = 5000
252 | ```
253 |
254 | Filtering out reddit posts based on the reddit post title length
255 |
256 | ```
257 | title_length_minimum = 20
258 | title_length_maximum = 100
259 | ```
260 |
261 | Filter out posts that exceed the maximum self text length
262 |
263 | ```
264 | maximum_length_self_text = 5000
265 | ```
266 |
267 | Filter out reddit posts that don't have enough comments
268 |
269 | ```
270 | minimum_num_comments = 200
271 | ```
272 |
273 | Only attempt to process a maximum amount of reddit posts
274 |
275 | ```
276 | submission_limit = 1000
277 | ```
278 |
279 | Specify how many thumbnail images you want to generate
280 |
281 | ```
282 | number_of_thumbnails = 3
283 | ```
284 |
285 | Specify the maximum video length
286 |
287 | ```
288 | max_video_length = 600 # Seconds
289 | ```
290 |
291 | Specify maximum amount of comments to generate in the video
292 |
293 | ```
294 | comment_limit = 600
295 | ```
296 |
297 | Specifying various folder paths
298 |
299 | ```
300 | assets_directory = "assets"
301 | temp_directory = "temp"
302 | audio_directory = str(Path("temp"))
303 | fonts_directory = str(Path(assets_directory,"fonts"))
304 | image_backgrounds_directory = str(Path(assets_directory,"image_backgrounds"))
305 | images_directory = str(Path(assets_directory,"images"))
306 | thumbnails_directory = str(Path(assets_directory,"images"))
307 | background_directory = str(Path(assets_directory,"backgrounds"))
308 | video_overlay_filepath = str(Path(assets_directory,"particles.mp4"))
309 | videos_directory = "videos"
310 | ```
311 |
312 | Specify video height and width
313 |
314 | ```
315 | video_height = 720
316 | video_width = 1280
317 | clip_size = (video_width, video_height)
318 | ```
319 |
320 | Skip compiling the video and just exit instead
321 |
322 | ```
323 | enable_compilation = True
324 | ```
325 |
326 | Skip uploading to youtube
327 |
328 | ```
329 | enable_upload = False
330 | ```
331 |
332 | Add a video overlay to the video, for example snow falling effect
333 |
334 | ```
335 | enable_overlay = True
336 | ```
337 |
338 | Add in a newscaster reader to the video
339 |
340 | ```
341 | enable_newscaster = True
342 | ```
343 |
344 | If newcaster video is a green screen attempt to remove the green screen
345 |
346 | ```
347 | newscaster_remove_greenscreen = True
348 | ```
349 |
350 | Specify the color of the green screen in RGB
351 |
352 | ```
353 | newscaster_greenscreen_color = [1, 255, 17] # Enter the Green Screen RGB Colour
354 | ```
355 |
356 | The higher the greenscreen threshold number the more it will attempt to remove
357 |
358 | ```
359 | newscaster_greenscreen_remove_threshold = 100
360 | ```
361 |
362 | Path to newcaster file
363 |
364 | ```
365 | newscaster_filepath = str(Path(assets_directory,"newscaster.mp4").resolve())
366 | ```
367 |
368 | Position on the screen of the newscaster
369 |
370 | ```
371 | newscaster_position = ("left","bottom")
372 | ```
373 |
374 | The size of the newscaster
375 |
376 | ```
377 | newcaster_size = (video_width * 0.5, video_height * 0.5)
378 | ```
379 |
380 | Add a pause after each text to speech audio file
381 |
382 | ```
383 | pause = 1 # Pause after speech
384 | ```
385 |
386 | Text style settings
387 |
388 | ```
389 | text_bg_color = "#1A1A1B"
390 | text_bg_opacity = 1
391 | text_color = "white"
392 | text_font = "Verdana-Bold"
393 | text_fontsize = 32
394 | ```
395 |
396 | Download images from lexica or skip trying to download
397 |
398 | ```
399 | lexica_download_enabled = True
400 | ```
401 |
402 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | """Main entrypoint for the bot."""
2 | import logging
3 | import os
4 | import platform
5 | import sys
6 | from argparse import ArgumentParser, Namespace
7 | from pathlib import Path
8 | from typing import List
9 |
10 | from praw.models import Submission
11 |
12 | import config.settings as settings
13 | import reddit.reddit as reddit
14 | import thumbnail.thumbnail as thumbnail
15 | import video_generation.video as vid
16 | from csvmgr import CsvWriter
17 | from utils.common import create_directory, safe_filename
18 |
19 | logging.basicConfig(
20 | format="%(asctime)s %(levelname)-8s %(message)s",
21 | level=logging.INFO,
22 | datefmt="%Y-%m-%d %H:%M:%S",
23 | handlers=[logging.FileHandler("debug.log", "w", "utf-8"), logging.StreamHandler()],
24 | )
25 |
26 |
27 | class Video:
28 | """Metadata used to splice content together to form a video."""
29 |
30 | def __init__(self, submission):
31 | """Initialize a Video instance.
32 |
33 | Args:
34 | submission: The Reddit post to be converted into a video.
35 | """
36 | self.submission = submission
37 | self.comments = []
38 | self.clips = []
39 | self.background = None
40 | self.music = None
41 | self.thumbnail_path = None
42 | self.folder_path = None
43 | self.video_filepath = None
44 |
45 |
46 | def process_submissions(submissions: List[Submission]) -> None:
47 | """Prepare multiple reddit posts for conversion into YouTube videos.
48 |
49 | Args:
50 | submissions: A list of zero or more Reddit posts to be converted.
51 | """
52 | post_total: int = settings.total_posts_to_process
53 | post_count: int = 0
54 |
55 | for submission in submissions:
56 | title_path: str = safe_filename(submission.title)
57 | folder_path: Path = Path(
58 | settings.videos_directory, f"{submission.id}_{title_path}"
59 | )
60 | video_filepath: Path = Path(folder_path, "final.mp4")
61 | if video_filepath.exists() or csvwriter.is_uploaded(submission.id):
62 | print(f"Final video already processed : {submission.id}")
63 | else:
64 | process_submission(submission)
65 | post_count += 1
66 | if post_count >= post_total:
67 | print("Reached post count total!")
68 | break
69 |
70 |
71 | def process_submission(submission: Submission) -> None:
72 | """Prepare a reddit post for conversion into a YouTube video.
73 |
74 | Args:
75 | submission: The Reddit post to be converted into to a video.
76 | """
77 | print("===== PROCESSING SUBMISSION =====")
78 | print(
79 | f"{str(submission.id)}, {str(submission.score)}, \
80 | {str(submission.num_comments)}, \
81 | {len(submission.selftext)}, \
82 | {submission.subreddit_name_prefixed}, \
83 | {submission.title}"
84 | )
85 | video: Video = Video(submission)
86 | title_path: str = safe_filename(submission.title)
87 |
88 | if settings.background_music_path:
89 | video.music = settings.background_music_path
90 |
91 | # Create Video Directories
92 | video.folder_path: str = str(
93 | Path(settings.videos_directory, f"{submission.id}_{title_path}")
94 | )
95 |
96 | create_directory(video.folder_path)
97 |
98 | video.video_filepath = str(Path(video.folder_path, "final.mp4"))
99 |
100 | if os.path.exists(video.video_filepath):
101 | print(f"Final video already compiled : {video.video_filepath}")
102 | else:
103 | # Generate Thumbnail
104 |
105 | thumbnails = None
106 | if not settings.enable_thumbnails:
107 | print("Skipping thumbnails generation...")
108 | else:
109 | thumbnails: List[Path] = thumbnail.generate(
110 | video_directory=video.folder_path,
111 | subreddit=submission.subreddit_name_prefixed,
112 | title=submission.title,
113 | number_of_thumbnails=settings.number_of_thumbnails,
114 | )
115 |
116 | if thumbnails:
117 | video.thumbnail_path = thumbnails[0]
118 |
119 | if args.thumbnail_only:
120 | print("Generating Thumbnail only skipping video compile!")
121 | else:
122 | vid.create(
123 | video_directory=video.folder_path,
124 | post=submission,
125 | thumbnails=thumbnails,
126 | )
127 |
128 |
129 | def banner():
130 | """Display the CLIs banner."""
131 | print("##### YOUTUBE REDDIT BOT #####")
132 |
133 |
134 | def print_version_info():
135 | """Display basic environment information."""
136 | print(f"OS Version : {platform.system()} {platform.release()}")
137 | print(f"Python Version : {sys.version}")
138 |
139 |
140 | def get_args() -> Namespace:
141 | """Generate arguments supported by the CLI utility.
142 |
143 | Returns:
144 | An argparse Namepsace containing the supported CLI parameters.
145 | """
146 | parser: ArgumentParser = ArgumentParser()
147 |
148 | parser.add_argument(
149 | "--enable-mentions",
150 | action="store_true",
151 | help="Check reddit account for u mentions",
152 | )
153 |
154 | parser.add_argument(
155 | "--enable-saved",
156 | action="store_true",
157 | help="Check reddit account for saved posts",
158 | )
159 |
160 | parser.add_argument(
161 | "--disable-selftext",
162 | action="store_true",
163 | help="Disable selftext video generation",
164 | )
165 |
166 | parser.add_argument(
167 | "--voice-engine",
168 | help="Specify which text to speech engine to use",
169 | choices=["polly", "balcon", "gtts", "tiktok", "edge-tts", "streamlabspolly"],
170 | )
171 |
172 | parser.add_argument(
173 | "-c",
174 | "--comment-style",
175 | help="Specify text based or reddit image comments",
176 | choices=["text", "reddit"],
177 | )
178 |
179 | parser.add_argument(
180 | "-l", "--video-length", help="Set how long you want the video to be", type=int
181 | )
182 |
183 | parser.add_argument(
184 | "-n", "--enable-nsfw", action="store_true", help="Allow NSFW Content"
185 | )
186 |
187 | parser.add_argument(
188 | "-o", "--disable-overlay", action="store_true", help="Disable video overlay"
189 | )
190 |
191 | parser.add_argument(
192 | "-s",
193 | "--story-mode",
194 | action="store_true",
195 | help="Generate video for post title and selftext only,\
196 | disables user comments",
197 | )
198 |
199 | parser.add_argument(
200 | "-t",
201 | "--thumbnail-only",
202 | action="store_true",
203 | help="Generate thumbnail image only",
204 | )
205 |
206 | parser.add_argument(
207 | "-p",
208 | "--enable-upload",
209 | action="store_true",
210 | help="Upload video to youtube, \
211 | requires client_secret.json and \
212 | credentials.storage to be valid",
213 | )
214 |
215 | parser.add_argument(
216 | "-u",
217 | "--url",
218 | help="Specify Reddit post url, \
219 | seperate with a comma for multiple posts.",
220 | )
221 |
222 | parser.add_argument("--subreddits", help="Specify Subreddits, seperate with +")
223 |
224 | parser.add_argument(
225 | "-b",
226 | "--enable-background",
227 | action="store_true",
228 | help="Enable video backgrounds",
229 | )
230 |
231 | parser.add_argument(
232 | "-m",
233 | "--enable-background-music",
234 | action="store_true",
235 | help="Enable background music",
236 | )
237 |
238 | parser.add_argument(
239 | "--background-music-path",
240 | help="Path to background music file"
241 | )
242 |
243 | parser.add_argument(
244 | "--thumbnail-image-path",
245 | help="Path to thumbnail image file"
246 | )
247 |
248 |
249 | parser.add_argument("--total-posts", type=int, help="Enable video backgrounds")
250 |
251 | parser.add_argument(
252 | "--submission-score", type=int, help="Minimum submission score threshold"
253 | )
254 |
255 | parser.add_argument(
256 | "--background-directory", help="Folder path to video backgrounds"
257 | )
258 |
259 | parser.add_argument("--sort", choices=["top", "hot"], help="Sort Reddit posts by")
260 |
261 | parser.add_argument(
262 | "--time",
263 | choices=["all", "day", "hour", "month", "week", "year"],
264 | default="day",
265 | help="Filter by time",
266 | )
267 |
268 | parser.add_argument(
269 | "--orientation",
270 | choices=["landscape", "portrait"],
271 | default="landscape",
272 | help="Sort Reddit posts by",
273 | )
274 |
275 | parser.add_argument(
276 | "--shorts", action="store_true", help="Generate Youtube Shorts Video"
277 | )
278 |
279 | args = parser.parse_args()
280 |
281 | if args.orientation:
282 | settings.orientation = args.orientation
283 | if args.orientation == "portrait":
284 | settings.video_height = settings.vertical_video_height
285 | settings.video_width = settings.vertical_video_width
286 |
287 | if args.shorts:
288 | logging.info("Generating Youtube Shorts Video")
289 | settings.shorts_mode_enabled = True
290 | settings.orientation = "portrait"
291 | settings.video_height = settings.vertical_video_height
292 | settings.video_width = settings.vertical_video_width
293 | settings.add_hashtag_shorts_to_description = True
294 | settings.text_fontsize = 60
295 | settings.clip_margin = 100
296 | settings.clip_margin_top = settings.vertical_video_height / 3
297 | settings.reddit_comment_width = 0.90
298 | settings.commentstyle = "text"
299 | settings.enable_background = True
300 | settings.max_video_length = 59
301 |
302 | if args.enable_mentions:
303 | settings.enable_reddit_mentions = True
304 | logging.info("Enable Generate Videos from User Mentions")
305 |
306 | if args.enable_saved:
307 | settings.enable_reddit_saved = True
308 | logging.info("Enable Generate Videos from Saved Posts")
309 |
310 |
311 | if args.submission_score:
312 | settings.minimum_submission_score = args.submission_score
313 | logging.info(
314 | "Setting Reddit Post Minimum Submission Score : %s",
315 | settings.minimum_submission_score,
316 | )
317 |
318 | if args.sort:
319 | settings.reddit_post_sort = args.sort
320 | logging.info("Setting Reddit Post Sort : %s", settings.reddit_post_sort)
321 | if args.time:
322 | settings.reddit_post_time_filter = args.time
323 | logging.info(
324 | "Setting Reddit Post Time Filter : %s", settings.reddit_post_time_filter
325 | )
326 |
327 | if args.background_directory:
328 | logging.info(
329 | "Setting video background directory : %s", args.background_directory
330 | )
331 | settings.background_directory = args.background_directory
332 |
333 | if args.enable_background_music:
334 | # Enable background music, selects random music file from assets/music
335 | logging.info(
336 | "Enable Background Music : True",
337 | )
338 | settings.enable_background_music = True
339 |
340 | if args.background_music_path:
341 | # If you want specific music use this to specify the path to the mp3 file.
342 | logging.info(
343 | "Specify background music file : %s", args.background_music_path
344 | )
345 | settings.enable_background_music = True
346 | settings.background_music_path = args.background_music_path
347 |
348 | if args.thumbnail_image_path:
349 | # If you want a specific thumbnail image specify the path using this arg.
350 | logging.info(
351 | "Settings thumbnail image path : %s", args.thumbnail_image_path
352 | )
353 | settings.thumbnail_image_path = args.thumbnail_image_path
354 |
355 | if args.total_posts:
356 | logging.info("Total Posts to process : %s", args.total_posts)
357 | settings.total_posts_to_process = args.total_posts
358 |
359 | if args.comment_style:
360 | logging.info("Setting comment style to : %s", args.comment_style)
361 | settings.commentstyle = args.comment_style
362 |
363 | if args.voice_engine:
364 | logging.info("Setting speech engine to : %s", args.voice_engine)
365 | settings.voice_engine = args.voice_engine
366 |
367 | if args.video_length:
368 | logging.info("Setting video length to : %s seconds", args.video_length)
369 | settings.max_video_length = args.video_length
370 |
371 | if args.disable_overlay:
372 | logging.info("Disabling Video Overlay")
373 | settings.enable_overlay = False
374 |
375 | if args.enable_nsfw:
376 | logging.info("Enable NSFW Content")
377 | settings.enable_nsfw_content = True
378 |
379 | if args.story_mode:
380 | logging.info("Story Mode Enabled!")
381 | settings.enable_comments = False
382 |
383 | if args.disable_selftext:
384 | logging.info("Disabled SelfText!")
385 | settings.enable_selftext = False
386 |
387 | if args.enable_upload:
388 | logging.info("Upload video enabled!")
389 | settings.enable_upload = True
390 |
391 | if args.subreddits:
392 | logging.info("Subreddits :")
393 | settings.subreddits = args.subreddits.split("+")
394 | print(settings.subreddits)
395 |
396 | if args.enable_background:
397 | logging.info("Enabling Video Background!")
398 | settings.enable_background = True
399 |
400 | logging.info("Setting Orientation to : %s", settings.orientation)
401 | logging.info("Setting video_height to : %s", settings.video_height)
402 | logging.info("Setting video_width to : %s", settings.video_width)
403 | logging.info("Setting clip_margin to : %s", settings.clip_margin)
404 | logging.info("Setting reddit_comment_width to : %s", settings.reddit_comment_width)
405 |
406 | return args
407 |
408 |
409 | if __name__ == "__main__":
410 | banner()
411 | print_version_info()
412 | args: Namespace = get_args()
413 | csvwriter: CsvWriter = CsvWriter()
414 | csvwriter.initialise_csv()
415 |
416 | submissions: List[Submission] = []
417 |
418 | if args.url:
419 | urls = args.url.split(",")
420 | for url in urls:
421 | submissions.append(reddit.get_reddit_submission(url))
422 | else:
423 | if settings.enable_reddit_mentions:
424 | logging.info("Getting Reddit Mentions")
425 | mention_posts = reddit.get_reddit_mentions()
426 | for mention_post in mention_posts:
427 | logging.info("Reddit Mention : %s", mention_post)
428 | submissions.append(reddit.get_reddit_submission(mention_post))
429 |
430 | if settings.enable_reddit_saved:
431 | logging.info("Getting Reddit Saved Posts")
432 | saved_posts = reddit.get_reddit_saved_posts()
433 | for saved_post in saved_posts:
434 | logging.info("Reddit Saved Post : %s", saved_post)
435 | submissions.append(reddit.get_reddit_submission(saved_post))
436 |
437 | reddit_posts: List[Submission] = reddit.posts()
438 | for reddit_post in reddit_posts:
439 | submissions.append(reddit_post)
440 |
441 | submissions = reddit.get_valid_submissions(submissions)
442 |
443 | if submissions:
444 | process_submissions(submissions)
445 |
--------------------------------------------------------------------------------
/assets/bin/geckodriver:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/bin/geckodriver
--------------------------------------------------------------------------------
/assets/fonts/impact.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/impact.ttf
--------------------------------------------------------------------------------
/assets/fonts/verdana.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/verdana.ttf
--------------------------------------------------------------------------------
/assets/fonts/verdanab.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/verdanab.ttf
--------------------------------------------------------------------------------
/assets/fonts/verdanai.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/verdanai.ttf
--------------------------------------------------------------------------------
/assets/fonts/verdanaz.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/fonts/verdanaz.ttf
--------------------------------------------------------------------------------
/assets/images/NicePng_evil-png_840970.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_evil-png_840970.png
--------------------------------------------------------------------------------
/assets/images/NicePng_masha-and-the-bear_1292475.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_masha-and-the-bear_1292475.png
--------------------------------------------------------------------------------
/assets/images/NicePng_meme-png_4338371.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_meme-png_4338371.png
--------------------------------------------------------------------------------
/assets/images/NicePng_roblox-girl-png_3028421.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_roblox-girl-png_3028421.png
--------------------------------------------------------------------------------
/assets/images/NicePng_spongegar-png_328488.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_spongegar-png_328488.png
--------------------------------------------------------------------------------
/assets/images/NicePng_twitch-emotes-png_4412701.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_twitch-emotes-png_4412701.png
--------------------------------------------------------------------------------
/assets/images/NicePng_tyler-the-creator-png_1696064.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/NicePng_tyler-the-creator-png_1696064.png
--------------------------------------------------------------------------------
/assets/images/PngItem_1241663.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/PngItem_1241663.png
--------------------------------------------------------------------------------
/assets/images/Whatshowhasnolikablecharacters.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/Whatshowhasnolikablecharacters.png
--------------------------------------------------------------------------------
/assets/images/centaur.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/centaur.png
--------------------------------------------------------------------------------
/assets/images/chicken.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/chicken.png
--------------------------------------------------------------------------------
/assets/images/dog2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/dog2.png
--------------------------------------------------------------------------------
/assets/images/duck.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/duck.png
--------------------------------------------------------------------------------
/assets/images/fairy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/fairy.png
--------------------------------------------------------------------------------
/assets/images/frog.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/frog.png
--------------------------------------------------------------------------------
/assets/images/gabe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/gabe.png
--------------------------------------------------------------------------------
/assets/images/ghost-dress.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/ghost-dress.png
--------------------------------------------------------------------------------
/assets/images/ghost.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/ghost.png
--------------------------------------------------------------------------------
/assets/images/harambe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/harambe.png
--------------------------------------------------------------------------------
/assets/images/kermifire.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/kermifire.png
--------------------------------------------------------------------------------
/assets/images/kermitgun.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/kermitgun.png
--------------------------------------------------------------------------------
/assets/images/kermitowel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/kermitowel.png
--------------------------------------------------------------------------------
/assets/images/kermitsuit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/kermitsuit.png
--------------------------------------------------------------------------------
/assets/images/lemonman.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/lemonman.png
--------------------------------------------------------------------------------
/assets/images/mermaid-g2d706be1f_1920.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/mermaid-g2d706be1f_1920.png
--------------------------------------------------------------------------------
/assets/images/monster.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/monster.png
--------------------------------------------------------------------------------
/assets/images/people/woman001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/people/woman001.jpg
--------------------------------------------------------------------------------
/assets/images/priest.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/priest.png
--------------------------------------------------------------------------------
/assets/images/python-reddit-youtube-bot-tutorial.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/python-reddit-youtube-bot-tutorial.png
--------------------------------------------------------------------------------
/assets/images/robot-g55cc67956_1920.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/robot-g55cc67956_1920.png
--------------------------------------------------------------------------------
/assets/images/shia.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/shia.png
--------------------------------------------------------------------------------
/assets/images/shibi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/shibi.png
--------------------------------------------------------------------------------
/assets/images/shockface.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/shockface.png
--------------------------------------------------------------------------------
/assets/images/successkid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/successkid.png
--------------------------------------------------------------------------------
/assets/images/woman-g618a29d8c_1920.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/woman-g618a29d8c_1920.png
--------------------------------------------------------------------------------
/assets/images/woman-g6cfce431d_1920.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/woman-g6cfce431d_1920.png
--------------------------------------------------------------------------------
/assets/images/woman-g8d743d166_1920.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/woman-g8d743d166_1920.png
--------------------------------------------------------------------------------
/assets/images/woman.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/woman.jpg
--------------------------------------------------------------------------------
/assets/images/xm5gsv_thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/xm5gsv_thumbnail.png
--------------------------------------------------------------------------------
/assets/images/xn8xzu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/xn8xzu.png
--------------------------------------------------------------------------------
/assets/images/young-woman-g4459282f6_1920.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/young-woman-g4459282f6_1920.png
--------------------------------------------------------------------------------
/assets/images/young-woman-gaa93b4883_1920.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/images/young-woman-gaa93b4883_1920.png
--------------------------------------------------------------------------------
/assets/intro.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/intro.mp4
--------------------------------------------------------------------------------
/assets/intro_welcome.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/intro_welcome.mp4
--------------------------------------------------------------------------------
/assets/intro_welcome_crop.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/intro_welcome_crop.mp4
--------------------------------------------------------------------------------
/assets/music/[Non-Copyrighted Music] Chill Jazzy Lofi Hip Hop (Royalty Free) Jazz Hop Music [9Ojb8t3T2Ng].mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/music/[Non-Copyrighted Music] Chill Jazzy Lofi Hip Hop (Royalty Free) Jazz Hop Music [9Ojb8t3T2Ng].mp3
--------------------------------------------------------------------------------
/assets/music/[Non-Copyrighted Music] Chill Jazzy Lofi Hip-Hop (Royalty Free) Jazz Hop Music [IygKZNWPM_0].mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/music/[Non-Copyrighted Music] Chill Jazzy Lofi Hip-Hop (Royalty Free) Jazz Hop Music [IygKZNWPM_0].mp3
--------------------------------------------------------------------------------
/assets/newscaster.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/newscaster.mp4
--------------------------------------------------------------------------------
/assets/newscaster.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/newscaster.png
--------------------------------------------------------------------------------
/assets/newscaster02.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/newscaster02.mp4
--------------------------------------------------------------------------------
/assets/particles.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/particles.mp4
--------------------------------------------------------------------------------
/assets/scary/transition/lightning.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/assets/scary/transition/lightning.mp4
--------------------------------------------------------------------------------
/balcon.exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/balcon.exe
--------------------------------------------------------------------------------
/comment_templates/dark_reddit_mockup/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dark Reddit Comment Template
4 |
5 |
6 |
145 |
146 |
147 |
148 |
208 |
209 |
--------------------------------------------------------------------------------
/comment_templates/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Reddit Comment Template
5 |
6 |
7 |
27 |
28 |
29 |
52 |
53 |
--------------------------------------------------------------------------------
/comment_templates/light_reddit_mockup/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Light Reddit Comment Template
4 |
5 |
6 |
145 |
146 |
147 |
148 |
208 |
209 |
--------------------------------------------------------------------------------
/comment_templates/old_reddit_mockup/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
368 |
369 |
370 |
419 |
420 |
421 |
--------------------------------------------------------------------------------
/comments/cookie-dark-mode.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "USER",
4 | "value": "eyJwcmVmcyI6eyJ0b3BDb250ZW50RGlzbWlzc2FsVGltZSI6MCwiZ2xvYmFsVGhlbWUiOiJSRURESVQiLCJuaWdodG1vZGUiOnRydWUsImNvbGxhcHNlZFRyYXlTZWN0aW9ucyI6eyJmYXZvcml0ZXMiOmZhbHNlLCJtdWx0aXMiOmZhbHNlLCJtb2RlcmF0aW5nIjpmYWxzZSwic3Vic2NyaXB0aW9ucyI6ZmFsc2UsInByb2ZpbGVzIjpmYWxzZX0sInRvcENvbnRlbnRUaW1lc0Rpc21pc3NlZCI6MH19",
5 | "domain": ".reddit.com",
6 | "path": "/"
7 | },
8 | {
9 | "name": "eu_cookie",
10 | "value": "{%22opted%22:true%2C%22nonessential%22:false}",
11 | "domain": ".reddit.com",
12 | "path": "/"
13 | }
14 | ]
15 |
--------------------------------------------------------------------------------
/comments/cookie-light-mode.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "eu_cookie",
4 | "value": "{%22opted%22:true%2C%22nonessential%22:false}",
5 | "domain": ".reddit.com",
6 | "path": "/"
7 | }
8 | ]
9 |
--------------------------------------------------------------------------------
/config/auth-env.py:
--------------------------------------------------------------------------------
1 | """Load authentication credentials from environment variables."""
2 | import os
3 |
4 | print("Getting Secrets from ENV's")
5 | # Reddit Praw
6 | praw_client_id = os.environ.get("PRAW_CLIENT_ID")
7 | praw_client_secret = os.environ.get("PRAW_CLIENT_SECRET")
8 | praw_user_agent = os.environ.get("PRAW_USER_AGENT")
9 | praw_password = os.environ.get("PRAW_PASSWORD")
10 | praw_username = os.environ.get("PRAW_USERNAME")
11 |
12 | # Amazon Polly
13 | aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID")
14 | aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY")
15 |
16 | # Rumble
17 | rumble_username = os.environ.get("RUMBLE_USERNAME")
18 | rumble_password = os.environ.get("RUMBLE_PASSWORD")
19 |
--------------------------------------------------------------------------------
/config/auth-example.py:
--------------------------------------------------------------------------------
1 | """Authentication credentials used by the bot."""
2 |
3 | # FIXME: move credentials out of file.
4 |
5 | # Reddit Praw
6 | praw_client_id = "xxxxxx" # noqa: S105
7 | praw_client_secret = "xxxxxx" # noqa: S105
8 | praw_user_agent = "xxxxxx"
9 | praw_username = "xxxxxx"
10 | praw_password = "xxxxxx" # noqa: S105
11 |
12 | # Amazon Polly
13 | aws_access_key_id = "xxxxxx"
14 | aws_secret_access_key = "xxxxxx" # noqa: S105
15 |
16 | # Rumble
17 | rumble_username = "xxxxxx"
18 | rumble_password = "xxxxxx" # noqa: S105
19 |
--------------------------------------------------------------------------------
/config/settings.py:
--------------------------------------------------------------------------------
1 | """Configuration settings for the bot."""
2 | from sys import platform
3 | from pathlib import Path
4 |
5 | subreddits = [
6 | "askreddit",
7 | "AmItheAsshole",
8 | "antiwork",
9 | "AskMen",
10 | "ChoosingBeggars",
11 | "hatemyjob",
12 | "NoStupidQuestions",
13 | "pettyrevenge",
14 | "Showerthoughts",
15 | "TooAfraidToAsk",
16 | "TwoXChromosomes",
17 | "unpopularopinion",
18 | "confessions",
19 | "confession",
20 | "singularity",
21 | ]
22 |
23 | subreddits_excluded = [
24 | "r/CFB",
25 | ]
26 |
27 | # Sort reddit posts by choices ["hot","top"]
28 | reddit_post_sort = "hot"
29 | # Sort by "all", "day", "hour", "month", "week", or "year"
30 | reddit_post_time_filter = "day"
31 |
32 | # Speech Settings
33 | edge_tts_voice = "en-GB-RyanNeural"
34 | pause = 1 # Pause after speech
35 | streamlabs_polly_voice = "Brian"
36 | tiktok_voice = "en_us_006"
37 |
38 | # Set the gtts language, examples :
39 | # English (en)
40 | # Spanish (es)
41 | # French (fr)
42 | # German (de)
43 | # Italian (it)
44 | # Portuguese (pt)
45 | # Dutch (nl)
46 | # Russian (ru)
47 | # Mandarin Chinese (zh-cn)
48 | # Japanese (ja)
49 | gtts_language = "en"
50 |
51 | # Choices ["polly","balcon","gtts","tiktok","edge-tts","streamlabspolly"]
52 | voice_engine = "edge-tts"
53 |
54 | # Comment Settings
55 | banned_keywords_base64 = "cG9ybixzZXgsamVya2luZyBvZmYsc2x1dCxyYXB\
56 | lLGZ1Y2sscmV0YXJkLG1vdGhlcmZ1Y2tlcixyYXBpc3Q="
57 | theme = "dark" # "dark"/"light"
58 | minimum_num_comments = 200
59 | reddit_comment_opacity = 1
60 | reddit_comment_width = 0.95
61 | comment_length_max = 600
62 | comment_limit = 100
63 | comment_screenshot_timeout = 30000
64 | screenshot_debug = False # if True enables breakpoints in critical parts of screenshot.py
65 | use_old_reddit = False # if True use old.reddit.com instead of reddit.com
66 | use_old_reddit_login = True # if True use old.reddit.com to login instead of reddit.com
67 | use_comments_permalinks = False # if True don't try to scrape subreddit page, use comment permalinks directly
68 |
69 | template_url = "dark_reddit_mockup"
70 | template_abbreviated_style = "new_reddit" # can be one of ["none", "old_reddit", "new_reddit"]
71 | use_template = True # if True loads template_url as a comment template, and fills it with data using the Reddit API (using PRAW)
72 | template_debug = True # if True writes template output html files beside screenshots
73 |
74 | # Video settings
75 | background_colour = [26, 26, 27]
76 | background_opacity = 0.5
77 | background_volume = 0.5
78 | # commentstyle = ["reddit", "text"]
79 | commentstyle = "reddit"
80 | enable_background = False
81 | enable_comments = True
82 | enable_compilation = True # if True compile video
83 | enable_nsfw_content = False
84 | enable_overlay = False
85 | enable_selftext = True
86 | enable_comments_audio = True # if True generate mp3 from comment text
87 | enable_thumbnails = True # if True generate post thumbnails
88 | enable_upload = False
89 | enable_screenshot_title_image = False
90 | enable_reddit_mentions = False
91 | enable_reddit_saved = False
92 | enable_background_music = False
93 | background_music_path = ""
94 | background_music_volume = 0.3 # 30 percent (0.3) of the original volume
95 | thumbnail_image_path = ""
96 | lexica_download_enabled = True # Download files from Lexica
97 | max_video_length = 600 # Seconds
98 | maximum_length_self_text = 3000
99 | minimum_submission_score = 2000
100 | number_of_thumbnails = 1
101 | submission_limit = 500
102 | text_bg_color = "#1A1A1B"
103 | text_bg_opacity = 1
104 | text_color = "white"
105 | text_font = "Verdana-Bold"
106 | text_fontsize = 32
107 | clip_margin = 50
108 | clip_margin_top = 30
109 | title_length_maximum = 100
110 | title_length_minimum = 20
111 | total_posts_to_process = 1
112 | video_height = 720
113 | video_width = 1280
114 | vertical_video_width = 1080
115 | vertical_video_height = 1920
116 | # Video Orientation choices ["landscape","portrait"]
117 | orientation = "landscape"
118 |
119 |
120 | clip_size = (video_width, video_height)
121 |
122 | # Thumbnail settings choices ['random','lexica']
123 | thumbnail_image_source = "lexica"
124 | thumbnail_text_width = video_width * 0.65
125 | enable_thumbnail_image_gradient = True
126 |
127 | # Directories and paths
128 | assets_directory = "assets"
129 | temp_directory = str(Path(assets_directory, "work_dir"))
130 | audio_directory = temp_directory
131 | speech_directory = temp_directory
132 | screenshot_directory = temp_directory
133 | fonts_directory = str(Path(assets_directory, "fonts"))
134 | image_backgrounds_directory = str(Path(assets_directory, "image_backgrounds"))
135 | images_directory = str(Path(assets_directory, "images"))
136 | thumbnails_directory = str(Path(assets_directory, "images"))
137 | background_directory = str(Path(assets_directory, "backgrounds"))
138 | music_directory = str(Path(assets_directory, "music"))
139 | soundeffects_directory = str(Path(assets_directory, "soundeffects"))
140 | video_overlay_filepath = str(Path(assets_directory, "particles.mp4"))
141 | videos_directory = "videos"
142 | add_hashtag_shorts_to_description = False
143 | shorts_mode_enabled = False
144 |
145 | # Youtube
146 | # Choices ['private', 'unlisted', 'public']
147 | youtube_privacy_status = "public"
148 |
149 | # Newcaster Settings
150 | enable_newscaster = False
151 | newscaster_remove_greenscreen = True
152 | newscaster_greenscreen_color = [1, 255, 17] # Enter Green Screen RGB Colour
153 | newscaster_greenscreen_remove_threshold = 100
154 | newscaster_filepath = str(Path(assets_directory, "newscaster.mp4").resolve())
155 | newscaster_position = ("left", "bottom")
156 | newcaster_size = (video_width * 0.5, video_height * 0.5)
157 |
158 | # Tweak for performance, set number of cores
159 | threads = 4
160 |
161 | # Whether to launch the Browser in Headless mode
162 | headless_browser = True # defaults to True, but can set it to False to see what happens
163 |
164 | if platform == "linux" or platform == "linux2":
165 | firefox_binary = "/opt/firefox/firefox"
166 | elif platform == "win32":
167 | firefox_binary = "C:\\Program Files\\Mozilla Firefox\\firefox.exe"
168 |
--------------------------------------------------------------------------------
/csvmgr.py:
--------------------------------------------------------------------------------
1 | """Manage the CSV file that contains metadata about Reddit posts."""
2 | import os
3 | from typing import Any, Dict
4 |
5 | import pandas as pd
6 | from pandas import DataFrame
7 | from typing import List
8 |
9 |
10 | class CsvWriter:
11 | """CSV Writer used to manage the bots database."""
12 |
13 | def __init__(self, csv_file="data.csv") -> None:
14 | """Initialize a new CSV Writer for managing the bots database.
15 |
16 | Args:
17 | csv_file: Path to the CSV file used to track video uploads.
18 | """
19 | self.csv_file = csv_file
20 | self.headers = ["id", "title", "thumbnail", "duration", "compiled", "uploaded"]
21 |
22 | def initialise_csv(self) -> None:
23 | """Initialise a fresh CSV file."""
24 | csv_exists = os.path.isfile(self.csv_file)
25 | if not csv_exists:
26 | new_csv: DataFrame = DataFrame(columns=self.headers)
27 | new_csv.to_csv(self.csv_file, index=False)
28 |
29 | def write_entry(self, row: Dict[str, Any]) -> None:
30 | """Save a new record to the CSV file.
31 |
32 | Args:
33 | row: New record containing metadata about a video.
34 | """
35 | csv: DataFrame = pd.read_csv(self.csv_file)
36 | entry: List[Dict[str, Any]] = [row]
37 | entry_df: DataFrame = DataFrame(entry)
38 | csv_entry: DataFrame = pd.concat([csv, entry_df])
39 | csv_entry.to_csv(self.csv_file, index=False)
40 |
41 | def is_uploaded(self, id: str) -> bool:
42 | """Validate whether a video has been uploaded.
43 |
44 | Args:
45 | id: Unique id of the video tbe validated.
46 |
47 | Returns:
48 | `True` if the video has already been uploaded, else `False`.
49 | """
50 | csv: DataFrame = pd.read_csv(self.csv_file)
51 |
52 | # TODO: ???
53 | results: int = len(csv.loc[(csv["id"] == id) & (csv["uploaded"] == True)])
54 | if results > 0:
55 | return True
56 | else:
57 | return False
58 |
59 | def set_uploaded(self, id: str) -> None:
60 | """Update the record to indicate the video has been uploaded.
61 |
62 | Args:
63 | id: Unique id of the record to be updated.
64 | """
65 | c: DataFrame = pd.read_csv(self.csv_file)
66 | for index, row in c.iterrows():
67 | if row["id"] == id:
68 | c.at[index, "uploaded"] = "true"
69 | c.to_csv(self.csv_file, index=False)
70 |
71 |
72 | if __name__ == "__main__":
73 | csvwriter: CsvWriter = CsvWriter()
74 | csvwriter.initialise_csv()
75 | print(csvwriter.is_uploaded("snppah"))
76 |
77 | csvwriter.set_uploaded("snppah")
78 | print(csvwriter.is_uploaded("snppah"))
79 |
--------------------------------------------------------------------------------
/dependencies/scripts/postCreateCommand.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Needed to keep poetry happy
4 | pip install --user --upgrade pip setuptools wheel
5 |
6 | # Needed to keep imagemagick happy
7 | sudo sed -i '/
9 | main()
10 | File "C:\Users\laverty\Documents\ttsvibelounge\app.py", line 61, in main
11 | post_video = video.create(post)
12 | File "C:\Users\laverty\Documents\ttsvibelounge\video.py", line 188, in create
13 | post_video.write_videofile(video_filename)
14 | File "", line 2, in write_videofile
15 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 54, in requires_duration
16 | return f(clip, *a, **k)
17 | File "", line 2, in write_videofile
18 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 135, in use_clip_fps_by_default
19 | return f(clip, *new_a, **new_kw)
20 | File "", line 2, in write_videofile
21 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 22, in convert_masks_to_RGB
22 | return f(clip, *a, **k)
23 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\video\VideoClip.py", line 293, in write_videofile
24 | self.audio.write_audiofile(audiofile, audio_fps,
25 | File "", line 2, in write_audiofile
26 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 54, in requires_duration
27 | return f(clip, *a, **k)
28 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 206, in write_audiofile
29 | return ffmpeg_audiowrite(self, filename, fps, nbytes, buffersize,
30 | File "", line 2, in ffmpeg_audiowrite
31 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 54, in requires_duration
32 | return f(clip, *a, **k)
33 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\io\ffmpeg_audiowriter.py", line 166, in ffmpeg_audiowrite
34 | for chunk in clip.iter_chunks(chunksize=buffersize,
35 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 85, in iter_chunks
36 | yield self.to_soundarray(tt, nbytes=nbytes, quantize=quantize,
37 | File "", line 2, in to_soundarray
38 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 54, in requires_duration
39 | return f(clip, *a, **k)
40 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 127, in to_soundarray
41 | snd_array = self.get_frame(tt)
42 | File "", line 2, in get_frame
43 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 89, in wrapper
44 | return f(*new_a, **new_kw)
45 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\Clip.py", line 93, in get_frame
46 | return self.make_frame(t)
47 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 296, in make_frame
48 | sounds = [c.get_frame(t - c.start)*np.array([part]).T
49 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\AudioClip.py", line 296, in
50 | sounds = [c.get_frame(t - c.start)*np.array([part]).T
51 | File "", line 2, in get_frame
52 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 89, in wrapper
53 | return f(*new_a, **new_kw)
54 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\Clip.py", line 93, in get_frame
55 | return self.make_frame(t)
56 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\Clip.py", line 136, in
57 | newclip = self.set_make_frame(lambda t: fun(self.get_frame, t))
58 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\fx\volumex.py", line 19, in
59 | return clip.fl(lambda gf, t: factor * gf(t),
60 | File "", line 2, in get_frame
61 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\decorators.py", line 89, in wrapper
62 | return f(*new_a, **new_kw)
63 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\Clip.py", line 93, in get_frame
64 | return self.make_frame(t)
65 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\io\AudioFileClip.py", line 77, in
66 | self.make_frame = lambda t: self.reader.get_frame(t)
67 | File "C:\Users\laverty\AppData\Local\Programs\Python\Python39\lib\site-packages\moviepy\audio\io\readers.py", line 170, in get_frame
68 | raise IOError("Error in file %s, "%(self.filename)+
69 | OSError: Error in file backgrounds\ULTRAWAVE_i7jU0xDABGM.mp4, Accessing time t=262.42-262.46 seconds, with clip duration=262 seconds,
70 | ```
--------------------------------------------------------------------------------
/docs/FEATURES.md:
--------------------------------------------------------------------------------
1 | ## Feature Requests
2 |
3 | * Loop background to fit length of video
4 | * Need an intro
5 | * Output video data to json file
6 | * [Deleted] comments are showing up in the video, filter them out
7 | * Support for emojis in text
8 | * Youtube Tags, get
9 | * Speech should handle Acronyms
10 | * YTA you're the asshole
11 | * NTA Not the asshole
12 | * AITA Am I The Asshole?
13 | * WTF What the Fuck
--------------------------------------------------------------------------------
/docs/OrderOfEvents.md:
--------------------------------------------------------------------------------
1 | # Order of events
2 |
3 | Documenting the tasks that take place when making a video out of a Reddit post.
4 |
5 |
6 | * Get arguments passed into script and update settings values.
7 |
8 | * Get reddit posts or use specific reddit post if someone has passed in a url
9 |
10 | * Create required directory and directory for the video to be created based on the reddit post title and id
11 |
12 | * Check if Video already exists
13 |
14 | * Generate thumbnails
15 |
16 | ** Size the text properly to fit the thumbnail
17 | ** select random colours for borders and text
18 |
19 | * get images from Lexica search engine to use for thumbnails
20 |
21 | * Generate Video
22 |
23 | ** Create Video Title
24 |
25 | ** Generate selftext clips
26 |
27 | * Add static video clip after selftext completes
28 |
29 | * Generate Clips for Comments
30 |
31 | ** Filter out bad comments based on criteria
32 |
33 | ** Screenshot downloader downloads comment images
34 |
35 | * Add background clip
36 |
37 | * Add overlay clip
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/docs/notes.md:
--------------------------------------------------------------------------------
1 | ```
2 | python app.py --enable-background --subreddits BreakUps+DeadBedrooms+LongDistance+RelationshipsOver35+dating_advice+polyamory+relationship_advice+relationshipproblems --story-mode
3 | ```
4 |
5 | ## Using Dev Containers
6 |
7 | A basic VSCode dev container has been made available to simplify local development.
8 |
9 | ### Pre-requisites
10 |
11 | - Visual Studio Code
12 | - Docker Desktop
13 |
14 | ### Configuring your local environment.
15 |
16 | Open the repo folder locally when performing the below steps, don't do this
17 | through a WSL remote connection, or the devcontainer build will fail. The
18 | assumption is that Docker Desktop is configured to use the WSL2 based engine.
19 |
20 | 1. Install the **Dev Containers** Visual Studio Code extension.
21 | 2. Press **Ctrl+Shift+P** to open the command palette and run
22 | "Dev Containers: Open workspace in container".
23 | 3. Wait about 15mins for the devcontainer to be built. Don't worry, this is
24 | only slow the first time the image is built. It will:
25 | - Install ImageMagick and update the security policy.
26 | - Download the latest version of yt-dlp.
27 | - Initialize playwright, including downloading the Firefox browser.
28 | - Install python dependencies needed to run the application, as defined in
29 | `requirements.txt`.
30 | - Install python dependencies needed for local development, as defined in
31 | `requirements-dev.txt`.
32 |
33 | ### Future Improvements
34 |
35 | - Pre-build the base image to reduce startup time.
36 | - Add firstrun script to configure settings.py.
37 | - Configure pre-commit to ensure static analysis/tests are executed during
38 | commit process.
--------------------------------------------------------------------------------
/libsamplerate.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/libsamplerate.dll
--------------------------------------------------------------------------------
/policy.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
9 | ]>
10 |
54 |
55 |
56 |
57 |
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 |
--------------------------------------------------------------------------------
/publish/login.py:
--------------------------------------------------------------------------------
1 | """Login module."""
2 | import json
3 | from typing import Dict, List
4 |
5 | from selenium.webdriver.common.by import By
6 | from selenium.webdriver.remote.webdriver import WebDriver
7 | from selenium.webdriver.support import expected_conditions as ec
8 | from selenium.webdriver.support.ui import WebDriverWait
9 |
10 |
11 | def domain_to_url(domain: str) -> str:
12 | """Convert a partial domain to valid URL.
13 |
14 | Args:
15 | domain: Domain to be converted.
16 |
17 | Returns:
18 | A fully qualified URL.
19 | """
20 | if domain == ".chrome.google.com/robots.txt":
21 | domain = "chrome.google.com/robots.txt"
22 | else:
23 | if domain.startswith("."):
24 | domain = f"www{domain}"
25 | return f"http://{domain}"
26 |
27 |
28 | def login_using_cookie_file(driver: WebDriver, cookie_file: str) -> None:
29 | """Restore auth cookies from a file.
30 |
31 | Does not guarantee that the user is logged in afterwards.
32 |
33 | Visits the domains specified in the cookies to set them, the previous page
34 | is not restored.
35 |
36 | Args:
37 | driver: Selenium webdriver.
38 | cookie_file: Existing, valid authentication cookie.
39 | """
40 | domain_cookies: Dict[str, List[object]] = {}
41 | with open(cookie_file) as file:
42 | cookies: List = json.load(file)
43 | # Sort cookies by domain, because we need to visit to domain to add cookies
44 | for cookie in cookies:
45 | try:
46 | domain_cookies[cookie["domain"]].append(cookie)
47 | except KeyError:
48 | domain_cookies[cookie["domain"]] = [cookie]
49 |
50 | for domain, cookies in domain_cookies.items():
51 | driver.get(domain_to_url(domain + "/robots.txt"))
52 | for cookie in cookies:
53 | cookie.pop("sameSite", None) # Attribute should be available in Selenium >4
54 | cookie.pop("storeId", None) # Firefox container attribute
55 | try:
56 | driver.add_cookie(cookie)
57 | except Exception:
58 | print(f"Couldn't set cookie {cookie['name']} for {domain}")
59 |
60 |
61 | def confirm_logged_in(driver: WebDriver) -> bool:
62 | """Confirm that the user is logged in.
63 |
64 | The browser needs to be navigated to a YouTube page.
65 |
66 | Args:
67 | driver: Selenium webdrive.
68 |
69 | Returns:
70 | `True` if the user is logged in, otherwise `False`.
71 | """
72 | try:
73 | WebDriverWait(driver, 5).until(
74 | ec.element_to_be_clickable((By.ID, "avatar-btn"))
75 | )
76 | return True
77 | except TimeoutError:
78 | return False
79 |
--------------------------------------------------------------------------------
/publish/upload.py:
--------------------------------------------------------------------------------
1 | """Upload a YouTube video."""
2 | import logging
3 | import re
4 | from datetime import datetime, timedelta
5 | from time import sleep
6 |
7 | from selenium.common.exceptions import (
8 | ElementNotInteractableException,
9 | NoSuchElementException,
10 | )
11 | from selenium.webdriver.common.by import By
12 | from selenium.webdriver.common.keys import Keys
13 | from selenium.webdriver.remote.webdriver import WebDriver
14 | from selenium.webdriver.remote.webelement import WebElement
15 | from selenium.webdriver.support import expected_conditions as ec
16 | from selenium.webdriver.support.ui import WebDriverWait
17 |
18 | today = datetime.now() + timedelta(minutes=15)
19 |
20 |
21 | def upload_file(
22 | driver: WebDriver,
23 | video_path: str,
24 | title: str,
25 | description: str,
26 | game: str = False,
27 | kids: bool = False,
28 | upload_time: datetime = None,
29 | thumbnail_path: str = None,
30 | ) -> None:
31 | """Upload a single video to YouTube.
32 |
33 | Args:
34 | driver: Selenium webdriver.
35 | video_path: Path to the video to be uploaded.
36 | title: Video title as it will appear on YouTube.
37 | description: Video description as it will appear on YouTube.
38 | game: Game that the video is associated with.
39 | kids: `True` to indicate that the video is suitable for viewing by
40 | kids, otherwise `false`.
41 | upload_time: Date and time that the video was uploaded.
42 | """
43 | WebDriverWait(driver, 20).until(
44 | ec.element_to_be_clickable((By.CSS_SELecTOR, "ytcp-button#create-icon"))
45 | ).click()
46 | WebDriverWait(driver, 20).until(
47 | ec.element_to_be_clickable(
48 | (By.XPATH, '//tp-yt-paper-item[@test-id="upload-beta"]')
49 | )
50 | ).click()
51 | video_input = driver.find_element(by=By.XPATH, value='//input[@type="file"]')
52 | logging.info("Setting Video File Path")
53 | video_input.send_keys(video_path)
54 |
55 | _set_basic_settings(driver, title, description, thumbnail_path)
56 | _set_advanced_settings(driver, game, kids)
57 | # Go to visibility settings
58 | for _i in range(3):
59 | WebDriverWait(driver, 20).until(
60 | ec.element_to_be_clickable((By.ID, "next-button"))
61 | ).click()
62 |
63 | # _set_time(driver, upload_time)
64 | try:
65 | _set_visibility(driver)
66 | except Exception:
67 | print("error uploading, continuing")
68 | _wait_for_processing(driver)
69 | # Go back to endcard settings
70 | # find_element(By.CSS_SELecTOR,"#step-badge-1").click()
71 | # _set_endcard(driver)
72 |
73 | # for _ in range(2):
74 | # # Sometimes, the button is clickable but clicking it raises an
75 | # # error, so we add a "safety-sleep" here
76 | # sleep(5)
77 | # WebDriverWait(driver, 20)
78 | # .until(ec.element_to_be_clickable((By.ID, "next-button")))
79 | # .click()
80 |
81 | # sleep(5)
82 | # WebDriverWait(driver, 20)
83 | # .until(ec.element_to_be_clickable((By.ID, "done-button")))
84 | # .click()
85 |
86 | # # Wait for the dialog to disappear
87 | # sleep(5)
88 | driver.close()
89 | logging.info("Upload is complete")
90 |
91 |
92 | def _wait_for_processing(driver: WebDriver) -> None:
93 | """Wait for YouTube to process the video.
94 |
95 | Calling this method will cause progress updates to be sent to stdout
96 | every 5 seconds until the processing is complete.
97 |
98 | Args:
99 | driver: Selenium webdriver.
100 | """
101 | logging.info("Waiting for processing to complete")
102 | # Wait for processing to complete
103 | progress_label: WebElement = driver.find_element(
104 | By.CSS_SELecTOR, "span.progress-label"
105 | )
106 | pattern = re.compile(r"(finished processing)|(processing hd.*)|(check.*)")
107 | current_progress = progress_label.get_attribute("textContent")
108 | last_progress = None
109 | while not pattern.match(current_progress.lower()):
110 | if last_progress != current_progress:
111 | logging.info(f"Current progress: {current_progress}")
112 | last_progress = current_progress
113 | sleep(5)
114 | current_progress = progress_label.get_attribute("textContent")
115 | if "Processing 99" in current_progress:
116 | print("Finished Processing!")
117 | sleep(10)
118 | break
119 |
120 |
121 | def _set_basic_settings(
122 | driver: WebDriver, title: str, description: str, thumbnail_path: str = None
123 | ) -> None:
124 | """Configure basic video settings.
125 |
126 | This function can be used to configure the videos title, description, and set
127 | the image that should be used as a thumbnail.
128 |
129 | Args:
130 | driver: Selenium webdriver.
131 | title: Video title.
132 | description: Video description.
133 | thumbnail_path: Path to be used to set the videos thumbnail, as it
134 | appears in YouTube search results.
135 | """
136 | logging.info("Setting Basic Settings")
137 | title_input: WebElement = WebDriverWait(driver, 20).until(
138 | ec.element_to_be_clickable(
139 | (
140 | By.XPATH,
141 | # '//ytcp-mention-textbox[@label="Title"]//div[@id="textbox"]',
142 | '//ytcp-social-suggestions-textbox[@id="title-textarea"]//\
143 | div[@id="textbox"]',
144 | )
145 | )
146 | )
147 |
148 | # Input meta data (title, description, etc ... )
149 | description_input: WebElement = driver.find_element(
150 | by=By.XPATH,
151 | # '//ytcp-mention-textbox[@label="Description"]//div[@id="textbox"]'
152 | value="//ytcp-social-suggestions-textbox[@label='Description']//\
153 | div[@id='textbox']",
154 | )
155 | thumbnail_input: WebElement = driver.find_element(
156 | By.CSS_SELecTOR, "input#file-loader"
157 | )
158 |
159 | title_input.clear()
160 | logging.info("Setting Video Title")
161 | title_input.send_keys(title)
162 | logging.info("Setting Video Description")
163 | description_input.send_keys(description)
164 | if thumbnail_path:
165 | logging.info("Setting Video Thumbnail")
166 | thumbnail_input.send_keys(thumbnail_path)
167 |
168 |
169 | def _set_advanced_settings(
170 | driver: WebDriver, game_title: str, made_for_kids: bool
171 | ) -> None:
172 | """Associate the video with a game, and/or flag it as suitable for kids.
173 |
174 | Args:
175 | driver: Selenium webdriver.
176 | game_title: Name of the game that the video is relevant to.
177 | made_for_kids: `True` to indicate the video is suitable for viewing by
178 | kids, otherwise `False`.
179 | """
180 | logging.info("Setting Advanced Settings")
181 | # Open advanced options
182 | driver.find_element(By.CSS_SELecTOR, "#toggle-button").click()
183 | if game_title:
184 | game_title_input: WebElement = driver.find_element(
185 | By.CSS_SELecTOR,
186 | ".ytcp-form-gaming > "
187 | "ytcp-dropdown-trigger:nth-child(1) > "
188 | ":nth-child(2) > div:nth-child(3) > input:nth-child(3)",
189 | )
190 | game_title_input.send_keys(game_title)
191 |
192 | # Select first item in game drop down
193 | WebDriverWait(driver, 20).until(
194 | ec.element_to_be_clickable(
195 | (
196 | By.CSS_SELecTOR,
197 | "#text-item-2", # The first item is an empty item
198 | )
199 | )
200 | ).click()
201 |
202 | WebDriverWait(driver, 20).until(
203 | ec.element_to_be_clickable(
204 | (
205 | By.NAME,
206 | "VIDEO_MADE_FOR_KIDS_MFK"
207 | if made_for_kids
208 | else "VIDEO_MADE_FOR_KIDS_NOT_MFK",
209 | )
210 | )
211 | ).click()
212 |
213 |
214 | def _set_endcard(driver: WebDriver) -> None:
215 | """Set the end card.
216 |
217 | Args:
218 | driver: Selenium webdriver.
219 | """
220 | logging.info("Endscreen")
221 |
222 | # Add endscreen
223 | driver.find_element(By.CSS_SELecTOR, "#endscreens-button").click()
224 | sleep(5)
225 |
226 | for i in range(1, 11):
227 | try:
228 | # Select endcard type from last video or first suggestion if no prev. video
229 | driver.find_element(By.CSS_SELecTOR, "div.card:nth-child(1)").click()
230 | break
231 | except (NoSuchElementException, ElementNotInteractableException):
232 | logging.warning(f"Couldn't find endcard button. Retry in 5s! ({i}/10)")
233 | sleep(5)
234 |
235 | WebDriverWait(driver, 20).until(
236 | ec.element_to_be_clickable((By.ID, "save-button"))
237 | ).click()
238 |
239 |
240 | def _set_time(driver: WebDriver, upload_time: datetime) -> None:
241 | """Schedule a time for the video to become available.
242 |
243 | Args:
244 | driver: Selenium webdriver.
245 | upload_time: Date and time that the video should be published.
246 | """
247 | # Start time scheduling
248 | WebDriverWait(driver, 20).until(
249 | ec.element_to_be_clickable((By.NAME, "SCHEDULE"))
250 | ).click()
251 |
252 | # Open date_picker
253 | driver.find_element(
254 | By.CSS_SELecTOR, "#datepicker-trigger > ytcp-dropdown-trigger:nth-child(1)"
255 | ).click()
256 |
257 | date_input: WebElement = driver.find_element(
258 | By.CSS_SELecTOR, "input.tp-yt-paper-input"
259 | )
260 | date_input.clear()
261 | # Transform date into required format: Mar 19, 2021
262 | date_input.send_keys(upload_time.strftime("%b %d, %Y"))
263 | date_input.send_keys(Keys.RETURN)
264 |
265 | # Open time_picker
266 | driver.find_element(
267 | By.CSS_SELecTOR,
268 | "#time-of-day-trigger > ytcp-dropdown-trigger:nth-child(1) > div:nth-child(2)",
269 | ).click()
270 |
271 | time_list = driver.find_elements(
272 | By.CSS_SELecTOR, "tp-yt-paper-item.tp-yt-paper-item"
273 | )
274 | # Transform time into required format: 8:15 PM
275 | time_str: str = upload_time.strftime("%I:%M %p").strip("0")
276 | time = [time for time in time_list[2:] if time.text == time_str][0]
277 | time.click()
278 |
279 |
280 | def _set_visibility(driver: WebDriver) -> None:
281 | """Make a published video public.
282 |
283 | Args:
284 | driver: Selenium webdriver.
285 | """
286 | # Start time scheduling
287 | logging.info("Setting Visibility to public")
288 | WebDriverWait(driver, 30).until(
289 | ec.element_to_be_clickable((By.NAME, "FIRST_CONTAINER"))
290 | ).click()
291 | WebDriverWait(driver, 30).until(
292 | ec.element_to_be_clickable((By.NAME, "PUBLIC"))
293 | ).click()
294 | sleep(10)
295 | WebDriverWait(driver, 30).until(
296 | ec.element_to_be_clickable((By.ID, "done-button"))
297 | ).click()
298 | # sleep(10)
299 | # WebDriverWait(driver, 30)
300 | # .until(ec.element_to_be_clickable((By.ID, "close-button"))).click()
301 |
--------------------------------------------------------------------------------
/publish/youtube.py:
--------------------------------------------------------------------------------
1 | """Upload a single video to YouTube."""
2 |
3 | from argparse import ArgumentParser, Namespace
4 | from dataclasses import dataclass, field
5 | from googleapiclient.discovery import build
6 | from googleapiclient.errors import HttpError
7 | from googleapiclient.http import MediaFileUpload
8 | from oauth2client.client import flow_from_clientsecrets
9 | from oauth2client.file import Storage
10 | from oauth2client.tools import argparser, run_flow
11 | from pathlib import Path
12 | import httplib2
13 | import logging
14 | import os
15 | import random
16 | import sys
17 | import time
18 |
19 | #import config.settings as settings
20 |
21 | logging.basicConfig(
22 | format="%(asctime)s %(levelname)-8s %(message)s",
23 | level=logging.INFO,
24 | datefmt="%Y-%m-%d %H:%M:%S",
25 | handlers=[logging.FileHandler("debug.log"), logging.StreamHandler()],
26 | )
27 |
28 | CLIENT_SECRETS_FILE = "client_secret.json"
29 | MAX_RETRIES = 10
30 | MISSING_CLIENT_SECRETS_MESSAGE = "MISSING_CLIENT_SECRETS_MESSAGE"
31 | RETRIABLE_EXCEPTIONS = (httplib2.HttpLib2Error, IOError)
32 | RETRIABLE_STATUS_CODES = [500, 502, 503, 504]
33 | YOUTUBE_API_SERVICE_NAME = "youtube"
34 | YOUTUBE_API_VERSION = "v3"
35 | YOUTUBE_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload"
36 |
37 | @dataclass
38 | class Video:
39 | """A video to be uploaded to YouTube."""
40 |
41 | filepath: Path = field(default_factory=Path)
42 | title: str = field(default_factory=str)
43 | thumbnail: Path = field(default_factory=Path)
44 | description: str = "This is my videos description"
45 |
46 | # Call the API's thumbnails.set method to upload the thumbnail image and
47 | # associate it with the appropriate video.
48 | def upload_thumbnail(youtube, video_id, file):
49 | print("Uploading and setting Youtube Thumbnail..")
50 | print(f"video_id: {video_id}")
51 | print(f"file: {file}")
52 | youtube.thumbnails().set(
53 | videoId=video_id,
54 | media_body=MediaFileUpload(file)
55 | ).execute()
56 |
57 | def publish(video: Video) -> None:
58 | flow = flow_from_clientsecrets(CLIENT_SECRETS_FILE,
59 | scope=YOUTUBE_UPLOAD_SCOPE,
60 | message=MISSING_CLIENT_SECRETS_MESSAGE)
61 |
62 | storage = Storage("credentials.storage")
63 | credentials = storage.get()
64 |
65 | if credentials is None or credentials.invalid:
66 | print("Credentials invalid...")
67 |
68 | youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION, http=credentials.authorize(httplib2.Http()))
69 |
70 | body = dict(
71 | snippet=dict(
72 | title=video.title,
73 | description=video.description,
74 | tags=["Reddit","Australia","Aussie","Banter"],
75 | categoryId="24"
76 | ),
77 | status=dict(
78 | privacyStatus="public",
79 | selfDeclaredMadeForKids=False
80 | )
81 | )
82 |
83 | insert_request = youtube.videos().insert(
84 | part=",".join(body.keys()),
85 | body=body,
86 | media_body=MediaFileUpload(video.filepath, chunksize=-1, resumable=True)
87 | )
88 |
89 | resumable_upload(video, youtube, insert_request)
90 |
91 |
92 |
93 | # This method implements an exponential backoff strategy to resume a
94 | # failed upload.
95 |
96 |
97 | def resumable_upload(video, youtube, insert_request):
98 | response = None
99 | error = None
100 | retry = 0
101 | while response is None:
102 | try:
103 | print("Uploading file...")
104 | status, response = insert_request.next_chunk()
105 | if response is not None:
106 | if 'id' in response:
107 | print("Video id '%s' was successfully uploaded." %
108 | response['id'])
109 | upload_thumbnail(youtube, response['id'], video.thumbnail)
110 | else:
111 | exit("The upload failed with an unexpected response: %s" % response)
112 | except HttpError as e:
113 | if e.resp.status in RETRIABLE_STATUS_CODES:
114 | error = "A retriable HTTP error %d occurred:\n%s" % (e.resp.status,
115 | e.content)
116 | else:
117 | raise
118 | except RETRIABLE_EXCEPTIONS as e:
119 | error = "A retriable error occurred: %s" % e
120 |
121 | if error is not None:
122 | print(error)
123 | retry += 1
124 | if retry > MAX_RETRIES:
125 | exit("No longer attempting to retry.")
126 |
127 | max_sleep = 2 ** retry
128 | sleep_seconds = random.random() * max_sleep
129 | print("Sleeping %f seconds and then retrying..." % sleep_seconds)
130 | time.sleep(sleep_seconds)
131 |
132 |
133 | if __name__ == "__main__":
134 | parser: ArgumentParser = ArgumentParser()
135 | parser.add_argument(
136 | "--filepath",
137 | default="D:\\src\\aussie-banter\\videos\\13r3zri_Is_a_sausage_roll_with_tomato_sauce_for_breakfast_\\final.mp4",
138 | help="Specify path to video file",
139 | )
140 | parser.add_argument(
141 | "--title",
142 | default="r/AusFinance Is a sausage roll with tomato sauce for breakfast common?",
143 | help="Video Title",
144 | )
145 | parser.add_argument(
146 | "--thumbnail",
147 | default="D:\\src\\aussie-banter\\videos\\13r3zri_Is_a_sausage_roll_with_tomato_sauce_for_breakfast_\\thumbnail_0.png",
148 | help="Video Thumbnail Image",
149 | )
150 | args: Namespace = parser.parse_args()
151 |
152 | video: Video = Video(
153 | filepath=args.filepath,
154 | title=args.title,
155 | thumbnail=args.thumbnail
156 | )
157 |
158 | publish(video)
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.flake8]
2 | max-line-length = 88
3 | extend-ignore = ['E203']
4 |
5 | [tool.isort]
6 | profile = "black"
7 |
8 | [tool.pylint.format]
9 | max-line-length = 88
10 |
11 | [tool.pycodestyle]
12 | max-line-length = 88
13 | ignore = ['E203']
14 |
--------------------------------------------------------------------------------
/reddit/reddit.py:
--------------------------------------------------------------------------------
1 | """Helpers used to retreive, filter and process reddit posts."""
2 | import praw
3 | import config.settings as settings
4 | import config.auth as auth
5 | import base64
6 | import re
7 | from praw.models import Submission
8 | from typing import List
9 |
10 |
11 | def is_valid_submission(submission) -> bool:
12 | """Determine whether a Reddit post is worth turning in to a video.
13 |
14 | A post is deemed "worthy", if:
15 | - It isn't stickied.
16 | - It was submitted by ttvibe.
17 | - The posts title is within the min/max ranges defined in settings.
18 | - The post hasn't been flagged as NSFW.
19 | - The post doesn't contain banned keywords.
20 | - The post wasn't made in a subreddit that has been added to the
21 | ignore list.
22 | - The submission score is within range.
23 | - The length of the post content is less than the configured maximum
24 | length.
25 | - The post has more than a minimum number of comments.
26 | - The post is not an update on a previous post.
27 | - The post doesn't contain 'covid' or 'vaccine' in the title, as these
28 | tend to trigger Youtube strikes. Censorship is double-plus good...
29 |
30 | Args:
31 | submission:
32 |
33 | Returns:
34 | `True` if the Reddit post is deemed worthy of turning in to a video,
35 | otherwise returns `False`.
36 | """
37 | if submission.stickied:
38 | return False
39 |
40 | if not settings.enable_screenshot_title_image and not submission.is_self:
41 | return False
42 |
43 | if (
44 | len(submission.title) < settings.title_length_minimum
45 | or len(submission.title) > settings.title_length_maximum
46 | ):
47 | return False
48 |
49 | if not settings.enable_nsfw_content:
50 | if submission.over_18:
51 | print("Skipping NSFW...")
52 | return False
53 | for banned_keyword in (
54 | base64.b64decode(settings.banned_keywords_base64.encode("ascii"))
55 | .decode("ascii")
56 | .split(",")
57 | ):
58 | if banned_keyword in submission.title.lower():
59 | print(
60 | f"{submission.title} \
61 | <-- Skipping post, title contains banned keyword!"
62 | )
63 | return False
64 |
65 | if submission.subreddit_name_prefixed in settings.subreddits_excluded:
66 | return False
67 |
68 | if submission.score < settings.minimum_submission_score:
69 | print(f"{submission.title} <-- Submission score too low!")
70 | return False
71 |
72 | if len(submission.selftext) > settings.maximum_length_self_text:
73 | return False
74 |
75 | if (
76 | settings.enable_comments
77 | and submission.num_comments < settings.minimum_num_comments
78 | ):
79 | print(f"{submission.title} <-- Number of comments too low!")
80 | return False
81 |
82 | if "update" in submission.title.lower():
83 | return False
84 |
85 | if "covid" in submission.title.lower() or "vaccine" in submission.title.lower():
86 | print(f"{submission.title} <-- Youtube Channel Strikes if Covid content...!")
87 | return False
88 |
89 | return True
90 |
91 |
92 | def get_reddit_submission(url: str) -> Submission:
93 | """Get a single Reddit post.
94 |
95 | Args:
96 | url: URL to the post to be retrieved.
97 |
98 | Returns:
99 | The post contents.
100 | """
101 | r: praw.Reddit = praw.Reddit(
102 | client_id=auth.praw_client_id,
103 | client_secret=auth.praw_client_secret,
104 | user_agent=auth.praw_user_agent,
105 | check_for_async=not settings.use_template # disable check (to hide warnings) if settings.use_template is True
106 | )
107 |
108 | submission: Submission = r.submission(url=url)
109 | return submission
110 |
111 |
112 | def get_reddit_mentions() -> List[str]:
113 | """Get a list of comments where ttvibe has been mentioned.
114 |
115 | Returns:
116 | A list containing zero or more URLs where ttvibe has been mentioned.
117 | """
118 | r: praw.Reddit = praw.Reddit(
119 | client_id=auth.praw_client_id,
120 | client_secret=auth.praw_client_secret,
121 | user_agent=auth.praw_user_agent,
122 | username=auth.praw_username,
123 | password=auth.praw_password,
124 | )
125 |
126 | mention_urls: List[str] = []
127 | for mention in r.inbox.mentions(limit=None):
128 | post_url: str = re.sub(
129 | rf"/{mention.id}/\?context=\d", "", mention.context, flags=re.IGNORECASE
130 | )
131 | mention_urls.append(f"https://www.reddit.com{post_url}")
132 |
133 | return mention_urls
134 |
135 | def get_reddit_saved_posts() -> List[str]:
136 | """Get a list of comments where ttvibe has been mentioned.
137 |
138 | Returns:
139 | A list containing zero or more URLs where ttvibe has been mentioned.
140 | """
141 | r: praw.Reddit = praw.Reddit(
142 | client_id=auth.praw_client_id,
143 | client_secret=auth.praw_client_secret,
144 | user_agent=auth.praw_user_agent,
145 | username=auth.praw_username,
146 | password=auth.praw_password,
147 | )
148 |
149 | saved_posts_urls: List[str] = []
150 | # Get the user's saved posts
151 | saved_posts = r.user.me().saved(limit=None) # 'limit=None' fetches all saved posts
152 |
153 | # Print the URLs of saved posts
154 | for post in saved_posts:
155 | print(post.url)
156 | saved_posts_urls.append(post.url)
157 |
158 | return saved_posts_urls
159 |
160 | def get_reddit_submissions() -> List[Submission]:
161 | """Get the latest Reddit posts.
162 |
163 | Posts will be retrieved according to the sort order defined in settings.
164 |
165 | Returns:
166 | A list containing zero or more Reddit posts.
167 | """
168 | r: praw.Reddit = praw.Reddit(
169 | client_id=auth.praw_client_id,
170 | client_secret=auth.praw_client_secret,
171 | user_agent=auth.praw_user_agent,
172 | check_for_async=not settings.use_template # disable check (to hide warnings) if settings.use_template is True
173 | )
174 |
175 | if settings.subreddits:
176 | subreddits: str = "+".join(settings.subreddits)
177 | else:
178 | subreddits: str = "all"
179 | print("Retrieving posts from subreddit :")
180 | print(subreddits)
181 |
182 | # Get Reddit Hot Posts
183 | if settings.reddit_post_sort == "hot":
184 | submissions: List[Submission] = r.subreddit(subreddits).hot(
185 | limit=settings.submission_limit
186 | )
187 |
188 | if settings.reddit_post_sort == "top":
189 | # Get Reddit Top Posts
190 | # "all", "day", "hour", "month", "week", or "year" (default: "all"
191 |
192 | submissions: List[Submission] = r.subreddit(subreddits).top(
193 | limit=settings.submission_limit,
194 | time_filter=settings.reddit_post_time_filter,
195 | )
196 |
197 | return submissions
198 |
199 |
200 | def get_valid_submissions(submissions: List[Submission]) -> List[Submission]:
201 | """Get Reddit posts deemed suitable for use as source material.
202 |
203 | Args:
204 | submissions: List of Reddit posts to validate.
205 |
206 | Returns:
207 | A list of zero or more Reddit posts that are suitable for use as source
208 | material, to generate a video.
209 | """
210 | valid_submissions: List[Submission] = []
211 | print("===== Retrieving valid Reddit submissions =====")
212 | print("ID, SCORE, NUM_COMMENTS, LEN_SELFTEXT, SUBREDDIT, TITLE")
213 | for submission in submissions:
214 | if is_valid_submission(submission):
215 | msg: str = ", ".join(
216 | [str(submission.id),
217 | str(submission.score),
218 | str(submission.num_comments),
219 | str(len(submission.selftext)),
220 | submission.subreddit_name_prefixed,
221 | submission.title]
222 | )
223 | print(msg)
224 | valid_submissions.append(submission)
225 |
226 | return valid_submissions
227 |
228 |
229 | def posts() -> List[Submission]:
230 | """Get a list of available Reddit posts.
231 |
232 | Returns:
233 | A list of zero or more Reddit submissions.
234 | """
235 | submissions: List[Submission] = get_reddit_submissions()
236 | return submissions
237 |
--------------------------------------------------------------------------------
/referral.txt:
--------------------------------------------------------------------------------
1 | https://www.buymeacoffee.com/ttsvibelounge
2 |
--------------------------------------------------------------------------------
/refresh_token.py:
--------------------------------------------------------------------------------
1 | """Helpers to fresh stale GitHub secrets."""
2 | import os
3 | from base64 import b64encode
4 | from pathlib import Path
5 | from typing import Any, Dict
6 |
7 | import requests
8 | from nacl import encoding
9 | from nacl.public import PublicKey, SealedBox
10 | from requests import Response
11 |
12 | github_user = "alexlaverty"
13 | github_repo = "ttsvibelounge"
14 | github_token = os.getenv("GH_TOKEN")
15 | secret_name = "CREDENTIALS_STORAGE" # noqa: S105
16 | api_endpoint = f"https://api.github.com/repos/{github_user}/{github_repo}"
17 |
18 |
19 | def get_repo_key() -> Dict:
20 | """Get the key to a GitHub repository.
21 |
22 | Returns:
23 | The GitHub repository key.
24 | """
25 | headers = {
26 | "Accept": "application/vnd.github+json",
27 | "X-GitHub-Api-Version": "2022-11-28",
28 | "Authorization": f"Bearer {github_token}",
29 | }
30 | return requests.get(
31 | f"{api_endpoint}/actions/secrets/public-key", headers=headers, timeout=120
32 | ).json()
33 |
34 |
35 | def encrypt(public_key: str, secret_value: str) -> str:
36 | """Encrypt a Unicode string using the public key.
37 |
38 | Args:
39 | public_key: Public encryption key.
40 | secret_value: Secret to be encrypted.
41 |
42 | Returns:
43 | Returns the base64 encoded, encrypted secret.
44 | """
45 | public_key: PublicKey = PublicKey(
46 | public_key.encode("utf-8"), encoding.Base64Encoder()
47 | )
48 | sealed_box: SealedBox = SealedBox(public_key)
49 | encrypted: Any = sealed_box.encrypt(secret_value.encode("utf-8"))
50 | return b64encode(encrypted).decode("utf-8")
51 |
52 |
53 | def write_secret(repo_key: Any, secret_encrypted_string: str) -> None:
54 | """Add a secret to GitHub.
55 |
56 | Args:
57 | repo_key: Name of the repository that should contain the secret.
58 | secret_encrypted_string: Secret value.
59 | """
60 | url: str = f"https://api.github.com/repos/{github_user}/{github_repo}/actions/secrets/{secret_name}"
61 | headers: Dict[str, str] = {
62 | "Accept": "application/vnd.github+json",
63 | "X-GitHub-Api-Version": "2022-11-28",
64 | "Authorization": f"Bearer {github_token}",
65 | }
66 | data: Dict[str, str] = {
67 | "encrypted_value": secret_encrypted_string,
68 | "key_id": repo_key["key_id"],
69 | }
70 | print("---------------------------------")
71 | print(url)
72 | print(headers)
73 | print(data)
74 | print("---------------------------------")
75 | x: Response = requests.put(url, json=data, headers=headers, timeout=120)
76 | print(x.text)
77 |
78 |
79 | def get_file_contents(filename: Path) -> str:
80 | """Read the contents of a text file.
81 |
82 | Args:
83 | filename: Path to the file to read.
84 |
85 | Returns:
86 | The file contents.
87 | """
88 | with open(filename, "r") as f:
89 | return f.read()
90 |
91 |
92 | file_contents: str = get_file_contents("credentials.storage")
93 |
94 | repo_key: Any = get_repo_key()
95 | print(repo_key)
96 |
97 | encrypted_secret: str = encrypt(repo_key["key"], file_contents)
98 | write_secret(repo_key, encrypted_secret)
99 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | dlint==0.14.1
2 | flake8==6.0.0
3 | flake8-bandit==4.1.1
4 | flake8-black==0.3.6
5 | flake8-bugbear==23.3.23
6 | flake8-docstrings==1.7.0
7 | flake8-isort==4.0.0
8 | flake8-pyproject==1.2.3
9 | flake8-secure-coding-standard==1.4.0
10 | mypy==1.2.0
11 | pep8-naming==0.13.3
12 | types-flake8-docstrings==1.7.0.1
13 | pandas-stubs==2.0.0.230412
14 | types-flake8-docstrings==1.7.0.1
15 | types-openpyxl==3.1.0.4
16 | types-pillow==9.5.0.2
17 | types-requests==2.29.0.0
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp==3.9.3
2 | aiosignal==1.3.1
3 | async-timeout==4.0.3
4 | attrs==23.2.0
5 | beautifulsoup4==4.12.3
6 | boto3==1.34.53
7 | botocore==1.34.53
8 | bs4==0.0.2
9 | build==1.1.1
10 | CacheControl==0.14.0
11 | cachetools==5.3.3
12 | certifi==2024.2.2
13 | cffi==1.16.0
14 | chardet==4.0.0
15 | charset-normalizer==3.3.2
16 | cleo==2.1.0
17 | click==8.1.7
18 | crashtest==0.4.1
19 | cryptography==42.0.5
20 | dbus-python==1.2.18
21 | decorator==4.4.2
22 | distlib==0.3.8
23 | distro-info==1.1+ubuntu0.2
24 | dulwich==0.21.7
25 | edge-tts==6.1.10
26 | emoji==2.10.1
27 | exceptiongroup==1.2.0
28 | executing==2.0.1
29 | fastjsonschema==2.19.1
30 | filelock==3.13.1
31 | frozenlist==1.4.1
32 | google-api-core==2.17.1
33 | google-api-python-client==2.120.0
34 | google-auth==2.28.1
35 | google-auth-httplib2==0.2.0
36 | googleapis-common-protos==1.62.0
37 | greenlet==3.0.3
38 | gTTS==2.5.1
39 | h11==0.14.0
40 | httplib2==0.22.0
41 | idna==3.6
42 | imageio==2.9.0
43 | imageio-ffmpeg==0.4.9
44 | importlib-metadata==7.0.1
45 | installer==0.7.0
46 | jaraco.classes==3.3.1
47 | jeepney==0.8.0
48 | Jinja2==3.1.3
49 | jmespath==1.0.1
50 | joblib==1.3.2
51 | keyring==24.3.1
52 | markdown-it-py==3.0.0
53 | MarkupSafe==2.1.5
54 | mdurl==0.1.2
55 | more-itertools==10.2.0
56 | moviepy==1.0.3
57 | msgpack==1.0.7
58 | multidict==6.0.5
59 | nltk==3.8.1
60 | numpy==1.26.4
61 | oauth2client==4.1.3
62 | outcome==1.3.0.post0
63 | packaging==23.2
64 | pandas==2.2.1
65 | pexpect==4.9.0
66 | Pillow==7.2.0
67 | pkginfo==1.9.6
68 | platformdirs==4.2.0
69 | playwright==1.41.2
70 | poetry==1.8.1
71 | poetry-core==1.9.0
72 | poetry-plugin-export==1.6.0
73 | praw==7.7.1
74 | prawcore==2.4.0
75 | proglog==0.1.10
76 | progressbar2==4.4.1
77 | protobuf==4.25.3
78 | ptyprocess==0.7.0
79 | pyasn1==0.5.1
80 | pyasn1-modules==0.3.0
81 | pycparser==2.21
82 | pyee==11.0.1
83 | Pygments==2.17.2
84 | PyGObject==3.42.1
85 | PyNaCl==1.5.0
86 | pyparsing==3.1.1
87 | pyproject_hooks==1.0.0
88 | pyser==0.1.5
89 | PySocks==1.7.1
90 | python-apt==2.4.0+ubuntu3
91 | python-dateutil==2.9.0
92 | python-debian==0.1.43+ubuntu1.1
93 | python-utils==3.8.2
94 | pytime==0.2.3
95 | pytz==2024.1
96 | PyYAML==5.4.1
97 | rapidfuzz==3.6.1
98 | regex==2023.12.25
99 | requests==2.31.0
100 | requests-toolbelt==1.0.0
101 | rich==13.7.1
102 | rsa==4.9
103 | s3transfer==0.10.0
104 | SecretStorage==3.3.3
105 | selenium==4.18.1
106 | shellingham==1.5.4
107 | simple-youtube-api==0.2.8
108 | six==1.16.0
109 | sniffio==1.3.1
110 | sortedcontainers==2.4.0
111 | soupsieve==2.5
112 | tomli==2.0.1
113 | tomlkit==0.12.4
114 | tqdm==4.66.2
115 | trio==0.24.0
116 | trio-websocket==0.11.1
117 | trove-classifiers==2024.2.23
118 | typing_extensions==4.10.0
119 | tzdata==2024.1
120 | ubuntu-advantage-tools==8001
121 | update-checker==0.18.0
122 | uritemplate==4.1.1
123 | urllib3==2.0.7
124 | varname==0.13.0
125 | virtualenv==20.25.1
126 | websocket-client==1.7.0
127 | wsproto==1.2.0
128 | yarl==1.9.4
129 | zipp==3.17.0
--------------------------------------------------------------------------------
/speech/speech.py:
--------------------------------------------------------------------------------
1 | """Transform text to speech."""
2 | import logging
3 | import os
4 | import re
5 | import subprocess # noqa: S404
6 | import textwrap
7 | from argparse import ArgumentParser, Namespace
8 | from pathlib import Path
9 | from typing import Any, List
10 |
11 | import boto3
12 | from gtts import gTTS
13 | from moviepy.editor import AudioFileClip, concatenate_audioclips
14 |
15 | import config
16 | import config.settings as settings
17 | from speech.streamlabs_polly import StreamlabsPolly
18 | from speech.tiktok import TikTok
19 | from utils.common import sanitize_text
20 |
21 | logging.basicConfig(
22 | format="%(asctime)s %(levelname)-8s %(message)s",
23 | level=logging.INFO,
24 | datefmt="%Y-%m-%d %H:%M:%S",
25 | handlers=[logging.FileHandler("debug.log", "w", "utf-8"), logging.StreamHandler()],
26 | )
27 |
28 |
29 | def process_speech_text(text: str) -> str:
30 | """Sanitize raw text.
31 |
32 | This will process raw text prior to converting it to speech, replacing
33 | common abbreviations with their full english version to ensure that the
34 | generated speech is intelligable.
35 |
36 | Args:
37 | text: Text to be sanitized.
38 |
39 | Returns:
40 | Updated text that has had common abbreviations converted to their
41 | full english equivalent.
42 | """
43 | text = text.replace(" AFAIK ", " as far as I know ")
44 | text = text.replace("AITA", " Am I The Asshole? ")
45 | text = text.replace(" AMA ", " Ask me anything ")
46 | text = text.replace(" ELI5 ", " Explain Like I'm Five ")
47 | text = text.replace(" IAMA ", " I am a ")
48 | text = text.replace("IANAD", " i am not a doctor ")
49 | text = text.replace("IANAL", " i am not a lawyer ")
50 | text = text.replace(" IMO ", " in my opinion ")
51 | text = text.replace(" NSFL ", " Not safe for life ")
52 | text = text.replace(" NSFW ", " Not safe for Work ")
53 | text = text.replace("NTA", " Not The Asshole ")
54 | text = text.replace(" SMH ", " Shaking my head ")
55 | text = text.replace("TD;LR", " too long didn't read ")
56 | text = text.replace("TDLR", " too long didn't read ")
57 | text = text.replace(" TIL ", " Today I Learned ")
58 | text = text.replace("YTA", " You're the asshole ")
59 | text = text.replace("SAHM", " stay at home mother ")
60 | text = text.replace("WIBTA", " would I be the asshole ")
61 | text = text.replace(" stfu ", " shut the fuck up ")
62 | text = text.replace(" OP ", " o p ")
63 | text = text.replace(" CB ", " choosing beggar ")
64 | text = text.replace("pettyrevenge", "petty revenge")
65 | text = text.replace("askreddit", "ask reddit")
66 | text = text.replace("twoxchromosomes", "two x chromosomes")
67 | text = text.replace("showerthoughts", "shower thoughts")
68 | text = text.replace("amitheasshole", "am i the asshole")
69 | text = text.replace("“", '"')
70 | text = text.replace("“", '"')
71 | text = text.replace("’", "'")
72 | text = text.replace("...", ".")
73 | text = text.replace("*", "")
74 | text = re.sub(r"(\[|\()[0-9]{1,2}\s*(m|f)?(\)|\])", "", text, flags=re.IGNORECASE)
75 | text = sanitize_text(text)
76 | return text
77 |
78 |
79 | def create_audio(path: Path, text: str) -> Path:
80 | """Generate an audio file using text to speech.
81 |
82 | Args:
83 | path: Path to save the generated audio file to.
84 | text: Text to be converted to speech.
85 |
86 | Returns:
87 | Path to the generated audio file.
88 | """
89 | # logging.info(f"Generating Audio File : {text}")
90 | output_path = os.path.normpath(path)
91 | text: str = process_speech_text(text)
92 | if not os.path.exists(output_path) or not os.path.getsize(output_path) > 0:
93 | if settings.voice_engine == "polly":
94 | polly_client: Any = boto3.Session(
95 | aws_access_key_id=config.aws_access_key_id,
96 | aws_secret_access_key=config.aws_secret_access_key,
97 | region_name="us-west-2",
98 | ).client("polly")
99 |
100 | response: Any = polly_client.synthesize_speech(
101 | Engine="neural", OutputFormat="mp3", Text=text, VoiceId="Matthew"
102 | )
103 |
104 | with open(output_path, "wb") as file: # noqa: SCS109
105 | file.write(response["AudioStream"].read())
106 |
107 | if settings.voice_engine == "gtts":
108 | ttmp3: Any = gTTS(text, lang=settings.gtts_language, slow=False)
109 | ttmp3.save(output_path)
110 |
111 | if settings.voice_engine == "streamlabspolly":
112 | slp: StreamlabsPolly = StreamlabsPolly()
113 | speech_text_character_limit: int = 550
114 |
115 | if len(text) > speech_text_character_limit:
116 | logging.info(
117 | "Text exceeds StreamlabsPolly limit, breaking up into chunks"
118 | )
119 | speech_chunks: List[Path] = []
120 | chunk_list: List[str] = textwrap.wrap(
121 | text,
122 | width=speech_text_character_limit,
123 | break_long_words=True,
124 | break_on_hyphens=False,
125 | )
126 | print(chunk_list)
127 |
128 | for count, chunk in enumerate(chunk_list):
129 | print(count)
130 | if chunk == "":
131 | logging.info("Skip zero space character comment : %s", chunk)
132 | continue
133 |
134 | if chunk == "":
135 | logging.info("Skipping blank comment")
136 | continue
137 |
138 | tmp_path: Path = f"{output_path}{count}"
139 | slp.run(chunk, tmp_path)
140 | speech_chunks.append(tmp_path)
141 |
142 | clips: List[AudioFileClip] = [AudioFileClip(c) for c in speech_chunks]
143 | final_clip: AudioFileClip = concatenate_audioclips(clips)
144 | final_clip.write_audiofile(output_path)
145 | else:
146 | print(text)
147 | slp.run(text, output_path)
148 |
149 | if settings.voice_engine == "edge-tts":
150 | print("+++ edge-tts +++")
151 | pattern = r'[^a-zA-Z0-9\s.,!?\'"-]' # You can customize this pattern as needed.
152 | # Use the re.sub() function to replace any characters that don't match the pattern with an empty string.
153 | text = re.sub(pattern, '', text)
154 | text = re.sub(r'\n+', ' ', text)
155 | text = re.sub(r'\s+', ' ', text)
156 |
157 | print(text)
158 | subprocess.run( # noqa: S603, S607
159 | [
160 | "edge-tts",
161 | "--voice",
162 | settings.edge_tts_voice,
163 | "--text",
164 | f"'{text.strip()}'",
165 | "--write-media",
166 | output_path,
167 | ]
168 | )
169 |
170 | if settings.voice_engine == "balcon":
171 | balcon_cmd: List[Any] = ["balcon.exe", "-w", output_path, "-t", f"{text}"]
172 | subprocess.call(balcon_cmd) # noqa: S603
173 |
174 | if settings.voice_engine == "tiktok":
175 | speech_text_character_limit: int = 200
176 | tt: TikTok = TikTok()
177 |
178 | if len(text) > speech_text_character_limit:
179 | logging.info(
180 | "Text exceeds tiktok limit, \
181 | breaking up into chunks"
182 | )
183 | speech_chunks: List[Path] = []
184 | chunk_list: List[str] = textwrap.wrap(
185 | text,
186 | width=speech_text_character_limit,
187 | break_long_words=True,
188 | break_on_hyphens=False,
189 | )
190 | print(chunk_list)
191 |
192 | for count, chunk in enumerate(chunk_list):
193 | print(count)
194 | if chunk == "":
195 | logging.info("Skip zero space character comment : %s", chunk)
196 | continue
197 |
198 | if chunk == "":
199 | logging.info("Skipping blank comment")
200 | continue
201 |
202 | tmp_path: Path = f"{path}{count}"
203 | tt.run(chunk, tmp_path)
204 | speech_chunks.append(tmp_path)
205 |
206 | clips: List[AudioFileClip] = [AudioFileClip(c) for c in speech_chunks]
207 | final_clip: AudioFileClip = concatenate_audioclips(clips)
208 | final_clip.write_audiofile(output_path)
209 | else:
210 | print(text)
211 | tt.run(text, output_path)
212 | else:
213 | logging.info("Audio file already exists : %s", output_path)
214 |
215 | logging.info("Created Audio File : %s", output_path)
216 |
217 | return output_path
218 |
219 |
220 | if __name__ == "__main__":
221 | parser: ArgumentParser = ArgumentParser()
222 | parser.add_argument(
223 | "--speech", default="Hello world this is a test.", help="Enter text for speech"
224 | )
225 | parser.add_argument(
226 | "--path", default="test_audio.mp3", help="Path to save audio file to"
227 | )
228 | args: Namespace = parser.parse_args()
229 |
230 | print(args.path)
231 | print(args.speech)
232 | create_audio(args.path, args.speech)
233 |
--------------------------------------------------------------------------------
/speech/streamlabs_polly.py:
--------------------------------------------------------------------------------
1 | """Polly text to speech convertor."""
2 | import sys
3 | import time as pytime
4 | from datetime import datetime
5 | from pathlib import Path
6 | from random import SystemRandom
7 | from time import sleep
8 | from typing import Dict, List, Union
9 |
10 | import requests
11 |
12 | # from utils import settings
13 | # from utils.voice import check_ratelimit
14 | from requests import Response
15 | from requests.exceptions import JSONDecodeError
16 |
17 | import config.settings as settings
18 | from utils.common import sanitize_text
19 |
20 | if sys.version_info[0] >= 3:
21 | from datetime import timezone
22 |
23 | voices = [
24 | "Brian",
25 | "Emma",
26 | "Russell",
27 | "Joey",
28 | "Matthew",
29 | "Joanna",
30 | "Kimberly",
31 | "Amy",
32 | "Geraint",
33 | "Nicole",
34 | "Justin",
35 | "Ivy",
36 | "Kendra",
37 | "Salli",
38 | "Raveena",
39 | ]
40 | # valid voices https://lazypy.ro/tts/
41 |
42 |
43 | def sleep_until(time: Union[int, datetime]) -> None:
44 | """Pause until a specific end time.
45 |
46 | Args:
47 | time: Either a valid datetime object or unix timestamp in seconds
48 | (i.e. seconds since Unix epoch).
49 | """
50 | end: int = time
51 |
52 | # Convert datetime to unix timestamp and adjust for locality
53 | if isinstance(time, datetime):
54 | # If we're on Python 3 and the user specified a timezone, convert to
55 | # UTC and get the timestamp.
56 | if sys.version_info[0] >= 3 and time.tzinfo:
57 | end: datetime = time.astimezone(timezone.utc).timestamp()
58 | else:
59 | zone_diff: float = (
60 | pytime.time() - (datetime.now() - datetime(1970, 1, 1)).total_seconds()
61 | )
62 | end: float = (time - datetime(1970, 1, 1)).total_seconds() + zone_diff
63 |
64 | # Type check
65 | if not isinstance(end, (int, float)):
66 | raise Exception("The time parameter is not a number or datetime object")
67 |
68 | # Now we wait
69 | while True:
70 | now: float = pytime.time()
71 | diff: float = end - now
72 |
73 | # Time is up!
74 | if diff <= 0:
75 | break
76 | else:
77 | # 'logarithmic' sleeping to minimize loop iterations
78 | sleep(diff / 2)
79 |
80 |
81 | def check_ratelimit(response: Response) -> bool:
82 | """Check if the rate limit has been hit.
83 |
84 | If it has, sleep for the time specified in the response.
85 |
86 | Args:
87 | response: The HTTP response to be examined.
88 |
89 | Returns:
90 | `True` if the rate limit has been reached, otherwise `False`.
91 | """
92 | if response.status_code == 429:
93 | try:
94 | time: int = int(response.headers["X-RateLimit-Reset"])
95 | print("Ratelimit hit, sleeping...")
96 | sleep_until(time)
97 | return False
98 | except KeyError: # if the header is not present, we don't know how long to wait
99 | return False
100 |
101 | return True
102 |
103 |
104 | class StreamlabsPolly:
105 | """Polly text to speech convertor."""
106 |
107 | def __init__(self):
108 | """Initialise a new Polly text to speech convertor."""
109 | self.url: str = "https://streamlabs.com/polly/speak"
110 | self.max_chars: int = 550
111 | self.voices: List[str] = voices
112 |
113 | def run(self, text: str, filepath: Path, random_voice: bool = False) -> None:
114 | """Convert text to an audio clip using a random Polly voice.
115 |
116 | Args:
117 | text: The text to be converted to speech.
118 | filepath: Path to save the generated audio clip to.
119 | random_voice: If `true`, selects a random voice, otherwise selects
120 | the default polly voice.
121 | """
122 | if random_voice:
123 | voice: str = self.randomvoice()
124 | else:
125 | if not settings.streamlabs_polly_voice:
126 | raise ValueError(
127 | f"Please set the config variable streamlabs_polly_voice \
128 | to a valid voice. options are: {voices}"
129 | )
130 | voice: str = str(settings.streamlabs_polly_voice).capitalize()
131 | text: str = sanitize_text(text)
132 | body: Dict[str, List[str]] = {"voice": voice, "text": text, "service": "polly"}
133 | response: Response = requests.post(self.url, data=body, timeout=120)
134 | if not check_ratelimit(response):
135 | self.run(text, filepath, random_voice)
136 | else:
137 | try:
138 | voice_data: Response = requests.get(
139 | response.json()["speak_url"], timeout=120
140 | )
141 | with open(filepath, "wb") as f: # noqa: SCS109
142 | f.write(voice_data.content)
143 | except (KeyError, JSONDecodeError):
144 | try:
145 | if response.json()["error"] == "No text specified!":
146 | raise ValueError("Please specify a text to convert to speech.")
147 | except (KeyError, JSONDecodeError):
148 | print("Error occurred calling Streamlabs Polly")
149 |
150 | def randomvoice(self) -> str:
151 | """Select a random Polly voice.
152 |
153 | Returns:
154 | The name of a randomly selected Polly voice.
155 | """
156 | rnd: SystemRandom() = SystemRandom()
157 | return rnd.choice(self.voices)
158 |
--------------------------------------------------------------------------------
/speech/tiktok.py:
--------------------------------------------------------------------------------
1 | """TikTok text to speech convertor."""
2 | import base64
3 | from random import SystemRandom
4 | import urllib.parse
5 | from pathlib import Path
6 | from typing import Dict, List
7 |
8 | import requests
9 | from requests import Response, Session
10 | from requests.adapters import HTTPAdapter, Retry
11 | from requests.exceptions import SSLError
12 |
13 | import config.settings as settings
14 |
15 | # from profanity_filter import ProfanityFilter
16 | # pf = ProfanityFilter()
17 | # Code by @JasonLovesDoggo
18 | # https://twitter.com/scanlime/status/1512598559769702406
19 |
20 | nonhuman = [ # DISNEY VOICES
21 | "en_us_ghostface", # Ghost Face
22 | "en_us_chewbacca", # Chewbacca
23 | "en_us_c3po", # C3PO
24 | "en_us_stitch", # Stitch
25 | "en_us_stormtrooper", # Stormtrooper
26 | "en_us_rocket", # Rocket
27 | # ENGLISH VOICES
28 | ]
29 |
30 | human = [
31 | "en_au_001", # English AU - Female
32 | "en_au_002", # English AU - Male
33 | "en_uk_001", # English UK - Male 1
34 | "en_uk_003", # English UK - Male 2
35 | "en_us_001", # English US - Female (Int. 1)
36 | "en_us_002", # English US - Female (Int. 2)
37 | "en_us_006", # English US - Male 1
38 | "en_us_007", # English US - Male 2
39 | "en_us_009", # English US - Male 3
40 | "en_us_010",
41 | ]
42 | voices = nonhuman + human
43 |
44 | noneng = [
45 | "fr_001", # French - Male 1
46 | "fr_002", # French - Male 2
47 | "de_001", # German - Female
48 | "de_002", # German - Male
49 | "es_002", # Spanish - Male
50 | # AMERICA VOICES
51 | "es_mx_002", # Spanish MX - Male
52 | "br_001", # Portuguese BR - Female 1
53 | "br_003", # Portuguese BR - Female 2
54 | "br_004", # Portuguese BR - Female 3
55 | "br_005", # Portuguese BR - Male
56 | # ASIA VOICES
57 | "id_001", # Indonesian - Female
58 | "jp_001", # Japanese - Female 1
59 | "jp_003", # Japanese - Female 2
60 | "jp_005", # Japanese - Female 3
61 | "jp_006", # Japanese - Male
62 | "kr_002", # Korean - Male 1
63 | "kr_003", # Korean - Female
64 | "kr_004", # Korean - Male 2
65 | ]
66 |
67 |
68 | # good_voices = {
69 | # 'good': ['en_us_002', 'en_us_006'],
70 | # 'ok': ['en_au_002', 'en_uk_001']}
71 | # less en_us_stormtrooper more less en_us_rocket en_us_ghostface
72 |
73 |
74 | class TikTok: # TikTok Text-to-Speech Wrapper
75 | """Text to Speech wrapper for TikTok."""
76 |
77 | def __init__(self):
78 | """Initialise a new TikTok text to speech generator."""
79 | self.URI_BASE: str = (
80 | "https://api16-normal-useast5.us.tiktokv.com"
81 | "/media/api/text/speech/invoke/?text_speaker="
82 | )
83 | self.max_chars: int = 300
84 | self.voices: Dict[str, List[str]] = {
85 | "human": human,
86 | "nonhuman": nonhuman,
87 | "noneng": noneng,
88 | }
89 |
90 | def run(self, text: str, filepath: Path, random_voice: bool = False) -> None:
91 | """Convert text to an audio clip using a random tik-tok styled voice.
92 |
93 | Args:
94 | text: The text to be converted to speech.
95 | filepath: Path to save the generated audio clip to.
96 | random_voice: If `true`, selects a random voice, otherwise selects
97 | the default tiktok voice.
98 | """
99 | # if censor:
100 | # req_text = pf.censor(req_text)
101 | # pass
102 | rnd: SystemRandom = SystemRandom()
103 | voice: str = (
104 | self.randomvoice()
105 | if random_voice
106 | else (settings.tiktok_voice or rnd.choice(self.voices["human"]))
107 | )
108 | try:
109 | text: str = urllib.parse.quote(text)
110 | print(len(text))
111 | tt_uri: str = f"{self.URI_BASE}{voice}&req_text={text}&speaker_map_type=0"
112 | print(tt_uri)
113 | r: Response = requests.post(tt_uri, timeout=120)
114 | except SSLError:
115 | # https://stackoverflow.com/a/47475019/18516611
116 | session: Session = Session()
117 | retry: Retry = Retry(connect=3, backoff_factor=0.5)
118 | adapter: HTTPAdapter = HTTPAdapter(max_retries=retry)
119 | session.mount("http://", adapter)
120 | session.mount("https://", adapter)
121 | r: Response = session.post(
122 | f"{self.URI_BASE}{voice}&req_text={text}&speaker_map_type=0"
123 | )
124 | # print(r.text)
125 | vstr: str = [r.json()["data"]["v_str"]][0]
126 | b64d: bytes = base64.b64decode(vstr)
127 |
128 | with open(filepath, "wb") as out: # noqa: SCS109
129 | out.write(b64d)
130 |
131 | def randomvoice(self) -> str:
132 | """Select a random voice.
133 |
134 | Returns:
135 | A randomly chosen human voice.
136 | """
137 | rnd: SystemRandom = SystemRandom()
138 | return rnd.choice(self.voices["human"])
139 |
140 |
141 | if __name__ == "__main__":
142 | tt: TikTok = TikTok()
143 | text_to_say: str = "Hello world this is some spoken text"
144 | print(str(len(text_to_say)))
145 | tt.run(text_to_say, "tiktok_test.mp3")
146 |
--------------------------------------------------------------------------------
/static.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexlaverty/python-reddit-youtube-bot/3a6cb5638dd07cc85feaa9d69c7ad0a268e019cb/static.mp4
--------------------------------------------------------------------------------
/test_upload.py:
--------------------------------------------------------------------------------
1 | """Test upload to YouTube."""
2 | import os
3 |
4 | from simple_youtube_api.Channel import Channel
5 | from simple_youtube_api.LocalVideo import LocalVideo
6 |
7 | directory = os.getcwd()
8 | print(directory)
9 |
10 |
11 | class Video:
12 | """Video metdata."""
13 |
14 | video = f"{directory}\\videos\\\
15 | ylg9ls_Does_anyone_know_how_a_loss_curve_like_this_can_ha\\\
16 | final.mp4"
17 | title = "What city will you NEVER visit based on it's reputation? \
18 | (r\\AskReddit)"
19 | thumbnail = f"{directory}\\videos\\\
20 | ylg9ls_Does_anyone_know_how_a_loss_curve_like_this_can_ha\
21 | \\thumbnail_0.png"
22 | description = "TTSVibeLounge"
23 |
24 |
25 | v = Video()
26 |
27 |
28 | # loggin into the channel
29 | channel = Channel()
30 | channel.login("client_secret.json", "credentials.storage")
31 |
32 | # setting up the video that is going to be uploaded
33 | video = LocalVideo(file_path=v.video)
34 |
35 | # setting snippet
36 | video.set_title(v.title)
37 | video.set_description(v.description)
38 | video.set_tags(["reddit", "tts"])
39 | video.set_category("gaming")
40 | video.set_default_language("en-US")
41 |
42 | # setting status
43 | video.set_embeddable(True)
44 | video.set_license("creativeCommon")
45 | video.set_privacy_status("private")
46 | video.set_public_stats_viewable(True)
47 |
48 | # setting thumbnail
49 | video.set_thumbnail_path(v.thumbnail)
50 |
51 | # uploading video and printing the results
52 | video = channel.upload_video(video)
53 | print(video.id)
54 | print(video)
55 |
--------------------------------------------------------------------------------
/thumbnail/keywords.py:
--------------------------------------------------------------------------------
1 | """Functions to extract key phrases out of text."""
2 | from multi_rake import Rake
3 |
4 |
5 | def get_keywords(text):
6 | """Extract key phrases out of a block of text.
7 |
8 | Args:
9 | text: The text to be processed.
10 |
11 | Returns:
12 | A selection of key phrases.
13 | """
14 | rake = Rake()
15 | keywords = rake.apply(text)
16 | return keywords
17 |
18 |
19 | if __name__ == "__main__":
20 | text = (
21 | "Compatibility of systems of linear constraints over the set of "
22 | "natural numbers. Criteria of compatibility of a system of linear "
23 | "Diophantine equations, strict inequations, and nonstrict inequations "
24 | "are considered. Upper bounds for components of a minimal set of "
25 | "solutions and algorithms of construction of minimal generating sets "
26 | "of solutions for all types of systems are given. These criteria and "
27 | "the corresponding algorithms for constructing a minimal supporting "
28 | "set of solutions can be used in solving all the considered types of "
29 | "systems and systems of mixed types."
30 | )
31 |
32 | keywords = get_keywords(text)
33 | print(keywords[:10])
34 |
--------------------------------------------------------------------------------
/thumbnail/lexica.py:
--------------------------------------------------------------------------------
1 | """Functions used to process and download images."""
2 | import json
3 | import logging
4 | import os
5 | import time
6 | import urllib
7 | from pathlib import Path
8 | from typing import List
9 | from urllib.request import Request, urlopen
10 | from requests import Response
11 | import requests
12 |
13 | import config.settings as settings
14 |
15 |
16 | def download_image(url: str, file_path: Path) -> None:
17 | """Download a single image.
18 |
19 | Args:
20 | url: URL to download the image from.
21 | file_path: Path to save the image to.
22 | """
23 | req: Request = Request(url, headers={"User-Agent": "Mozilla/5.0"})
24 | # FIXME: This is a potential security issue
25 | raw_img = urlopen(req).read() # noqa: S310
26 |
27 | with open(file_path, "wb") as f: # noqa: SCS109
28 | f.write(raw_img)
29 | f.close()
30 |
31 | # Sleeping to prevent being Rate LimitedF
32 | time.sleep(5)
33 |
34 |
35 | def get_images(
36 | folder_path: Path, sentence: str, number_of_images: int = 1
37 | ) -> List[Path]:
38 | """Download random images that are relevant to the provided text.
39 |
40 | Args:
41 | folder_path: Path to save the images.
42 | sentence: Text used to find relevant images.
43 | number_of_images: Number of images to be downloaded.
44 |
45 | Returns:
46 | A list of files path to the downloaded images.
47 | """
48 | images: List[Path] = []
49 |
50 | if settings.lexica_download_enabled:
51 | if number_of_images > 0:
52 | safe_query: str = urllib.parse.quote(sentence.strip())
53 | lexica_url: str = f"https://lexica.art/api/v1/search?q={safe_query}"
54 | logging.info("Downloading Image From Lexica : %s", sentence)
55 | try:
56 | r: Response = requests.get(lexica_url, timeout=120)
57 | j: object = json.loads(r.text)
58 | except Exception:
59 | print("Error Retrieving Lexica Images")
60 | pass
61 | return
62 |
63 | for num in range(0, number_of_images):
64 | image_path: Path = Path(folder_path, f"lexica_{num}.png")
65 | if not os.path.exists(image_path):
66 | image_url: str = j["images"][num]["src"]
67 | download_image(image_url, image_path)
68 | else:
69 | logging.info("Image already exists : %s - %s", id, sentence)
70 | images.append(image_path)
71 | else:
72 | logging.info("Downloading Images Set to False.......")
73 |
74 | return images
75 |
--------------------------------------------------------------------------------
/thumbnail/thumbnail.py:
--------------------------------------------------------------------------------
1 | """Functions used to download random images associated with a reddit post.
2 |
3 | This module also contains functions that can take those images and generate
4 | a series of video clips that can be spliced together to form the final video.
5 | """
6 | import logging
7 | import os
8 | import sys
9 | from pathlib import Path
10 |
11 | # from nltk.corpus import stopwords
12 | from random import SystemRandom
13 | from typing import Any, List
14 |
15 | from moviepy.editor import CompositeVideoClip, ImageClip, TextClip
16 | from PIL import Image
17 |
18 | import config.settings as settings
19 | import thumbnail.lexica as lexica
20 | from utils.common import random_rgb_colour, sanitize_text
21 |
22 | logging.basicConfig(
23 | format="%(asctime)s %(levelname)-8s %(message)s",
24 | level=logging.INFO,
25 | datefmt="%Y-%m-%d %H:%M:%S",
26 | handlers=[logging.FileHandler("debug.log", "w", "utf-8"), logging.StreamHandler()],
27 | )
28 |
29 |
30 | def apply_black_gradient(
31 | path_in: Path,
32 | path_out: Path = "out.png",
33 | gradient: float = 1.0,
34 | initial_opacity: float = 1.0,
35 | ) -> None:
36 | """Apply a black gradient to the image, going from left to right.
37 |
38 | Args:
39 | path_in: Path to image to apply gradient to.
40 | path_out: Path to save result to (default 'out.png').
41 | gradient: Gradient of the gradient, should be non-negative (default 1.0).
42 | if gradient = 0., the image is black;
43 | if gradient = 1., the gradient smoothly varies over the full width;
44 | if gradient > 1., the gradient terminates before the end of the width;
45 | initial_opacity: Scales the initial opacity of the gradient, eg: on the
46 | far left of the image (default 1.0).
47 |
48 | Should be between 0.0 and 1.0. Values between 0.9-1.0 generally
49 | give good results.
50 | """
51 | # get image to operate on
52 | input_im: Image = Image.open(path_in)
53 | if input_im.mode != "RGBA":
54 | input_im = input_im.convert("RGBA")
55 | width, height = input_im.size
56 |
57 | # create a gradient that
58 | # starts at full opacity * initial_value
59 | # decrements opacity by gradient * x / width
60 | alpha_gradient: Image = Image.new("L", (width, 1), color=0xFF)
61 | for x in range(width):
62 | a: int = int((initial_opacity * 255.0) * (1.0 - gradient * float(x) / width))
63 | if a > 0:
64 | alpha_gradient.putpixel((x, 0), a)
65 | else:
66 | alpha_gradient.putpixel((x, 0), 0)
67 | # print '{}, {:.2f}, {}'.format(x, float(x) / width, a)
68 | alpha: Any = alpha_gradient.resize(input_im.size)
69 |
70 | # create black image, apply gradient
71 | black_im: Image = Image.new("RGBA", (width, height), color=0) # i.e. black
72 | black_im.putalpha(alpha)
73 |
74 | # make composite with original image
75 | output_im: Image = Image.alpha_composite(input_im, black_im)
76 | output_im.save(path_out, "PNG")
77 |
78 | return
79 |
80 |
81 | def get_font_size(length: int) -> int:
82 | """Select a preset font size based on the amount of available space.
83 |
84 | Args:
85 | length: The amount of horizontal space available to insert text.
86 |
87 | Returns:
88 | A suggested font size.
89 | """
90 | fontsize = 50
91 | lineheight = 60
92 |
93 | if length < 10:
94 | fontsize = 190
95 |
96 | if length >= 10 and length < 20:
97 | fontsize = 150
98 |
99 | if length >= 20 and length < 30:
100 | fontsize = 150
101 |
102 | if length >= 30 and length < 40:
103 | fontsize = 130
104 |
105 | if length >= 40 and length < 50:
106 | fontsize = 110
107 |
108 | if length >= 50 and length < 60:
109 | fontsize = 100
110 |
111 | if length >= 60 and length < 70:
112 | fontsize = 90
113 |
114 | if length >= 70 and length < 80:
115 | fontsize = 85
116 |
117 | if length >= 80 and length < 90:
118 | fontsize = 90
119 |
120 | if length >= 90 and length < 100:
121 | fontsize = 80
122 |
123 | logging.debug("Title Length : %s", length)
124 | logging.debug("Setting Fontsize : %s", fontsize)
125 | logging.debug("Setting Lineheight : %s", lineheight)
126 |
127 | return fontsize, lineheight
128 |
129 |
130 | def generate(
131 | video_directory: Path,
132 | subreddit: str,
133 | title: str,
134 | number_of_thumbnails: int = 1,
135 | ) -> List[Path]:
136 | """Generate a series of thumbnails to be included in the generated video.
137 |
138 | The images will be randomly selected by lexica using the name of the
139 | subreddit, and post title as keywords to find relevant images.
140 |
141 | Args:
142 | video_directory: Path to save the images to.
143 | subreddit: Name of the subreddit that contains the post.
144 | title: Name of the reddit post.
145 | number_of_thumbnails: Number of relevant images to download
146 | (default=1).
147 |
148 | Returns:
149 | A list of paths where the downloaded images can be found.
150 | """
151 | logging.info("========== Generating Thumbnail ==========")
152 |
153 | # image_path = str(Path(video_directory, "lexica.png").absolute())
154 |
155 | title = sanitize_text(title).strip()
156 |
157 | # Get rid of double spaces
158 | title = title.replace(" ", " ")
159 | title = title.replace("’", "")
160 |
161 | logging.info(title)
162 |
163 | if settings.thumbnail_image_source == "random":
164 | rnd: SystemRandom = SystemRandom()
165 | random_image = rnd.choice(os.listdir(settings.images_directory))
166 | random_image_filepath = str(
167 | Path(settings.images_directory, random_image).absolute()
168 | )
169 | images = [random_image_filepath]
170 |
171 | if settings.thumbnail_image_source == "lexica":
172 | images = lexica.get_images(
173 | video_directory, title, number_of_images=number_of_thumbnails
174 | )
175 |
176 | if settings.thumbnail_image_path:
177 | images = [settings.thumbnail_image_path]
178 |
179 | thumbnails = []
180 |
181 | if images:
182 | for index, image in enumerate(images):
183 | thumbnail = create_thumbnail(
184 | video_directory, subreddit, title, image, index
185 | )
186 | thumbnails.append(thumbnail)
187 |
188 | return thumbnails
189 |
190 |
191 | def create_thumbnail(
192 | video_directory: Path, subreddit: str, title: str, image: Path, index: int = 0
193 | ) -> Path:
194 | """Generate a thumbnail image for the video.
195 |
196 | Args:
197 | video_directory: Path to save the thumbnail in.
198 | subreddit: Name of the subreddit that the post was made in.
199 | title: Name of the Reddit post.
200 | image: Path to the image used to create the thumbnail.
201 | index: Image offset.
202 |
203 | Returns:
204 | The full path to the updated video containing the thumbnail.
205 | """
206 | clips: List[Any] = []
207 |
208 | thumbnail_path: Path = Path(
209 | video_directory, f"thumbnail_{str(index)}.png"
210 | ).absolute()
211 | if thumbnail_path.exists():
212 | logging.info("Thumbnail already exists : %s", thumbnail_path)
213 | return thumbnail_path
214 |
215 | border_width: int = 20
216 | height: int = 720 - (border_width * 2)
217 | width: int = 1280 - (border_width * 2)
218 |
219 | background_clip: Any = TextClip(
220 | "", size=(width, height), bg_color="#000000", method="caption"
221 | )
222 |
223 | # .margin(border_width, color=random_rgb_colour())
224 |
225 | clips.append(background_clip)
226 |
227 | gradient_out: Path = Path(video_directory, "gradient.png").absolute()
228 |
229 | if settings.enable_thumbnail_image_gradient:
230 | apply_black_gradient(
231 | image, path_out=gradient_out, gradient=1.3, initial_opacity=0.9
232 | )
233 | image = gradient_out
234 |
235 | # img_width = width / 2
236 |
237 | img_clip = (
238 | ImageClip(str(image))
239 | .resize(height=height)
240 | .set_position(("right", "center"))
241 | .set_opacity(1)
242 | )
243 |
244 | img_clip = img_clip.set_position(("right", "center"))
245 |
246 | # img_clip = img_clip.set_position((width - img_clip.w , 0 + border_width))
247 |
248 | clips.append(img_clip)
249 |
250 | def get_text_clip(text: str, fs: int, txt_color: str = "#FFFFFF") -> TextClip:
251 | """Generate a video containing text.
252 |
253 | Args:
254 | text: The text to include in the video.
255 | fs: The font-size, expressed as an integer.
256 | txt_color: Colour to be used for the text, expressed in hex format.
257 |
258 | Returns:
259 | The generated video clip containing the specified text.
260 | """
261 | txt_clip = TextClip(
262 | text,
263 | fontsize=fs,
264 | color=txt_color,
265 | align="west",
266 | font="Impact",
267 | stroke_color="#000000",
268 | stroke_width=3,
269 | method="caption",
270 | size=(settings.thumbnail_text_width, 0),
271 | )
272 | return txt_clip
273 |
274 | # fontsize = 40
275 | title = title.replace("’", "")
276 | fontsize, lineheight = get_font_size(len(title))
277 | logging.info("Title Length : %s", len(title))
278 | logging.info("Optimising Font Size : ")
279 | sys.stdout.write(str(fontsize))
280 |
281 | while True:
282 | previous_fontsize: int = fontsize
283 | fontsize += 1
284 | txt_clip: TextClip = get_text_clip(title.upper(), fontsize)
285 | sys.stdout.write(".")
286 | if txt_clip.h > height:
287 | optimal_font_size: int = previous_fontsize
288 | print(optimal_font_size)
289 | break
290 |
291 | word_height: TextClip = get_text_clip(
292 | "Hello", fs=optimal_font_size, txt_color="#FFFFFF"
293 | )
294 | # word_height = TextClip(
295 | # "Hello",
296 | # fontsize=optimal_font_size,
297 | # align="center",
298 | # font="Impact",
299 | # stroke_color="#000000",
300 | # stroke_width=3,
301 | # )
302 |
303 | txt_y: int = 0
304 | txt_x: int = 15
305 |
306 | words: List[str] = title.upper().split(" ")
307 | word_color: str = "#FFFFFF"
308 | line_number: int = 1
309 | space_width: int = 9
310 | line_colours: List[str] = [
311 | "#46F710",
312 | "#F9CD10",
313 | "#04FF74",
314 | "#FF5252",
315 | "#FF7545",
316 | "#09C1F9",
317 | "#EFFF00",
318 | "#E7AD61",
319 | "#00677B",
320 | ]
321 |
322 | rnd = SystemRandom()
323 | rnd.shuffle(line_colours)
324 |
325 | for word in words:
326 | if (line_number % 2) == 0:
327 | word_color: str = line_colours[line_number]
328 | else:
329 | word_color: str = "#FFFFFF"
330 |
331 | # txt_clip = get_text_clip(word,
332 | # optimal_font_size,
333 | # txt_color=word_color)\
334 | # .set_position((txt_x, txt_y))
335 |
336 | txt_clip: TextClip = TextClip(
337 | word,
338 | fontsize=optimal_font_size,
339 | color=word_color,
340 | align="west",
341 | font="Impact",
342 | stroke_color="#000000",
343 | stroke_width=3,
344 | method="caption",
345 | ).set_position((txt_x, txt_y))
346 |
347 | current_text_width: int = txt_x + space_width + txt_clip.w
348 |
349 | if current_text_width > settings.thumbnail_text_width:
350 | txt_x = 15
351 | txt_y += word_height.h * 0.85
352 | line_number += 1
353 |
354 | if (line_number % 2) == 0:
355 | word_color: str = line_colours[line_number]
356 | else:
357 | word_color: str = "#FFFFFF"
358 |
359 | # txt_clip = get_text_clip(word,
360 | # fs=optimal_font_size,
361 | # txt_color=word_color)\
362 | # .set_position((txt_x, txt_y))
363 | txt_clip: TextClip = TextClip(
364 | word,
365 | fontsize=optimal_font_size,
366 | color=word_color,
367 | align="west",
368 | font="Impact",
369 | stroke_color="#000000",
370 | stroke_width=3,
371 | method="caption",
372 | ).set_position((txt_x, txt_y))
373 |
374 | clips.append(txt_clip)
375 | txt_x += txt_clip.w + space_width
376 |
377 | txt_clip = txt_clip.set_duration(10)
378 | txt_clip = txt_clip.set_position(("center", "center"))
379 |
380 | final_video: CompositeVideoClip = CompositeVideoClip(clips)
381 | final_video = final_video.margin(border_width, color=random_rgb_colour())
382 |
383 | logging.info("Saving Thumbnail to : %s", thumbnail_path)
384 | final_video.save_frame(thumbnail_path, 1)
385 | return thumbnail_path
386 |
387 |
388 | if __name__ == "__main__":
389 | from argparse import ArgumentParser, Namespace
390 |
391 | parser: ArgumentParser = ArgumentParser()
392 | parser.add_argument(
393 | "-d",
394 | "--directory",
395 | help="Specify directory to save thumbnail image to",
396 | default=".",
397 | )
398 | parser.add_argument(
399 | "-s", "--subreddit", help="Specify subreddit", default="r/AskReddit"
400 | )
401 | parser.add_argument(
402 | "-t",
403 | "--title",
404 | help="Specify Post Title",
405 | default="What's your most gatekeeping culinary opinion?",
406 | )
407 | parser.add_argument(
408 | "-i",
409 | "--image",
410 | help="Specify Thumbnail Image",
411 | default=Path(settings.images_directory, "woman.jpg").absolute(),
412 | )
413 |
414 | args: Namespace = parser.parse_args()
415 |
416 | create_thumbnail(".", "Something", args.title, args.image, index=0)
417 |
418 | # generate(args.directory,
419 | # args.subreddit,
420 | # args.title,
421 | # number_of_thumbnails=1)
422 |
--------------------------------------------------------------------------------
/utils/base64_encoding.py:
--------------------------------------------------------------------------------
1 | """Base64 encoding utilities."""
2 | import base64
3 |
4 |
5 | def base64_encode_string(message: str) -> None:
6 | """Encode a string to base64.
7 |
8 | Args:
9 | message: The string to be encoded.
10 | """
11 | # TODO: What is my purpose?
12 | message_bytes = message.encode("ascii")
13 | base64_bytes = base64.b64encode(message_bytes)
14 | base64_message = base64_bytes.decode("ascii")
15 | print(base64_message)
16 |
17 |
18 | def base64_decode_string(message: str) -> str:
19 | """Decode a base64 encoded string to ASCII format.
20 |
21 | Args:
22 | message: The base64 string to be decoded.
23 |
24 | Returns:
25 | The decoded ASCII string.
26 | """
27 | return base64.b64decode(message.encode("ascii")).decode("ascii")
28 |
29 |
30 | # Ignored keywords Testing the encoding function?
31 | message = "porn,sex,jerking off,slut,rape"
32 | base64_encode_string(message)
33 |
34 | # Encoded keywords. Testing the decoding function?
35 | message = "cG9ybixzZXgsamVya2luZyBvZmYsc2x1dCxyYXBl"
36 | decoded_string = base64_decode_string(message)
37 | print(decoded_string)
38 | print(decoded_string.split(","))
39 |
--------------------------------------------------------------------------------
/utils/common.py:
--------------------------------------------------------------------------------
1 | """Common helpers to keep the codebase DRY."""
2 | from random import SystemRandom
3 | import re
4 | import os
5 | from pathlib import Path
6 |
7 |
8 | def random_hex_colour() -> str:
9 | """Generate a random hex value that represents a colour.
10 |
11 | Returns:
12 | A HTML colour expressed in hex format, for example - 0F37A1.
13 | """
14 | rnd: SystemRandom = SystemRandom()
15 | r = rnd.randint(0, 255)
16 | rcolor = "#%02X%02X%02X" % (r(), r(), r())
17 | return rcolor
18 |
19 |
20 | def random_rgb_colour() -> int:
21 | """Generate a random integer between 0 and 255.
22 |
23 | Returns:
24 | A random integer.
25 | """
26 | rnd: SystemRandom = SystemRandom()
27 | rbg_colour: int = rnd.choices(range(256), k=3)
28 | print("Generated random rgb colour")
29 | print(rbg_colour)
30 | return rbg_colour
31 |
32 |
33 | def give_emoji_free_text(data: str) -> str:
34 | """Strip special characters from a string.
35 |
36 | This function will santize strings by removing emoticons, symbols and
37 | chinese characters.
38 |
39 | Args:
40 | data: The raw string to be processed.
41 |
42 | Returns:
43 | A santized string with special characters removed.
44 | """
45 | emoj = re.compile(
46 | "["
47 | "\U0001F600-\U0001F64F" # emoticons
48 | "\U0001F300-\U0001F5FF" # symbols & pictographs
49 | "\U0001F680-\U0001F6FF" # transport & map symbols
50 | "\U0001F1E0-\U0001F1FF" # flags (iOS)
51 | "\U00002500-\U00002BEF" # chinese char
52 | "\U00002702-\U000027B0"
53 | "\U00002702-\U000027B0"
54 | "\U000024C2-\U0001F251"
55 | "\U0001f926-\U0001f937"
56 | "\U00010000-\U0010ffff"
57 | "\u2640-\u2642"
58 | "\u2600-\u2B55"
59 | "\u200d"
60 | "\u23cf"
61 | "\u23e9"
62 | "\u231a"
63 | "\ufe0f" # dingbats
64 | "\u3030"
65 | "]+",
66 | re.UNICODE,
67 | )
68 | return re.sub(emoj, "", data)
69 |
70 |
71 | def safe_filename(text: str) -> str:
72 | """Replace all spaces in a string with an underscore.
73 |
74 | Args:
75 | text: string to be processed.
76 |
77 | Returns:
78 | The processed string, with all spaces converted to underscores.
79 | """
80 | text = text.replace(" ", "_")
81 | return "".join([c for c in text if re.match(r"\w", c)])[:50]
82 |
83 |
84 | def create_directory(folder_path: Path) -> None:
85 | """Create a directory path on the filesystem.
86 |
87 | Args:
88 | folder_path: Path to be created.
89 | """
90 | if not os.path.exists(folder_path):
91 | os.makedirs(folder_path)
92 |
93 |
94 | def contains_url(text: str) -> bool:
95 | """Validate if a string is a valid URL.
96 |
97 | This just does a simple protocol check. If the string starts with http://
98 | or https://, it's assumed to be a valid URL.
99 |
100 | Args:
101 | text: string to be evaluated.
102 |
103 | Returns:
104 | `True` if the string is a URL, otherwise `False`.
105 | """
106 | matches = ["http://", "https://"]
107 | if any(x in text for x in matches):
108 | return True
109 |
110 |
111 | def sanitize_text(text: str) -> str:
112 | r"""Sanitize the text for tts.
113 |
114 | What gets removed:
115 | - following characters `^_~@!&;#:-%“”‘"%*/{}[]()\|<>?=+`
116 | - any http or https links
117 |
118 | Args:
119 | text: Text to be sanitized
120 |
121 | Returns:
122 | str: Sanitized text
123 | """
124 | # Remove age related info
125 | # fmt: off
126 | text: str = re.sub(
127 | r"(\[|\()[0-9]{1,2}\s*(m|f)?(\)|\])",
128 | "",
129 | text,
130 | flags=re.IGNORECASE
131 | )
132 |
133 | # remove any urls from the text
134 | regex_urls: str = (
135 | r"((http|https)\:\/\/)"
136 | r"?[a-zA-Z0-9\.\/\?\:@\-_=#]"
137 | r"+\."
138 | r"([a-zA-Z]){2,6}"
139 | r"([a-zA-Z0-9\.\&\/\?\:@\-_=#])"
140 | r"*"
141 | )
142 |
143 | result: str = re.sub(regex_urls, " ", text)
144 |
145 | # note: not removing apostrophes
146 | regex_expr: str = r"\s['|’]|['|’]\s|[\^_~@!&;#:\-–—%“”‘\"%\*/{}\[\]\(\)\\|<>=+]"
147 | result: str = re.sub(regex_expr, " ", result)
148 | result = result.replace("+", "plus").replace("&", "and")
149 |
150 | # remove extra whitespace
151 | # return " ".join(result.split())
152 | return result
153 |
--------------------------------------------------------------------------------
/utils/speed_test.py:
--------------------------------------------------------------------------------
1 | """Does things and tests the speed."""
2 |
--------------------------------------------------------------------------------
372 | 373 |
374 |380 | [–] 381 | {{author}} 382 | 383 | {{score}} 384 | 0 385 | Answer Link 386 | 60 points 387 | {{score}} points 388 | 62 points 389 | 390 | (14 children) 391 |
392 | 398 |399 |-
400 | permalink
401 |
402 | -
403 | embed
404 |
405 | -
406 | save
407 |
408 | -
409 | report
410 |
411 |
414 |
415 | 416 |