assets
------------------------------------------
fonts

Roboto-Black.ttf
Roboto-Bold.ttf
Roboto-Medium.ttf
Roboto-Regular.ttf
Rubik-Black.ttf
Rubik-Black.ttf%3AZone.Identifier
---------------------------------------------
GUI

-voices
backgrounds.html

{% extends "layout.html" %}
{% block main %}

<!-- Delete Background Modal -->
<div class="modal fade" id="deleteBtnModal" tabindex="-1" role="dialog" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Delete background</h5>
            </div>
            <div class="modal-body">
                Are you sure you want to delete this background?
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
                <form action="background/delete" method="post">
                    <input type="hidden" id="background-key" name="background-key" value="">
                    <button type="submit" class="btn btn-danger">Delete</button>
                </form>
            </div>
        </div>
    </div>
</div>

<!-- Add Background Modal -->
<div class="modal fade" id="backgroundAddModal" tabindex="-1" role="dialog" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Add background video</h5>
            </div>
            <div class="modal-body">

                <!-- Add video form -->
                <form id="addBgForm" action="background/add" method="post" novalidate>
                    <div class="form-group row">
                        <label class="col-4 col-form-label" for="youtube_uri">YouTube URI</label>
                        <div class="col-8">
                            <div class="input-group">
                                <div class="input-group-text">
                                    <i class="bi bi-youtube"></i>
                                </div>
                                <input name="youtube_uri" placeholder="https://www.youtube.com/watch?v=..." type="text"
                                    class="form-control">
                            </div>
                            <span id="feedbackYT" class="form-text feedback-invalid"></span>
                        </div>
                    </div>
                    <div class="form-group row">
                        <label for="filename" class="col-4 col-form-label">Filename</label>
                        <div class="col-8">
                            <div class="input-group">
                                <div class="input-group-text">
                                    <i class="bi bi-file-earmark"></i>
                                </div>
                                <input name="filename" placeholder="Example: cool-background" type="text"
                                    class="form-control">
                            </div>
                            <span id="feedbackFilename" class="form-text feedback-invalid"></span>
                        </div>
                    </div>
                    <div class="form-group row">
                        <label for="citation" class="col-4 col-form-label">Credits (owner of the video)</label>
                        <div class="col-8">
                            <div class="input-group">
                                <div class="input-group-text">
                                    <i class="bi bi-person-circle"></i>
                                </div>
                                <input name="citation" placeholder="YouTube Channel" type="text" class="form-control">
                            </div>
                            <span class="form-text text-muted">Include the channel name of the
                                owner of the background video you are adding.</span>
                        </div>
                    </div>
                    <div class="form-group row">
                        <label for="position" class="col-4 col-form-label">Position of screenshots</label>
                        <div class="col-8">
                            <div class="input-group">
                                <div class="input-group-text">
                                    <i class="bi bi-arrows-fullscreen"></i>
                                </div>
                                <input name="position" placeholder="Example: center" type="text" class="form-control">
                            </div>
                            <span class="form-text text-muted">Advanced option (you can leave it
                                empty). Valid options are "center" and decimal numbers</span>
                        </div>
                    </div>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
                <button name="submit" type="submit" class="btn btn-success">Add background</button>
                </form>
            </div>
        </div>
    </div>
</div>

<main>
    <div class="album py-2 bg-light">
        <div class="container">

            <div class="row justify-content-between mt-2">
                <div class="col-12 col-md-3 mb-3">
                    <input type="text" class="form-control searchFilter" placeholder="Search backgrounds"
                        onkeyup="searchFilter()">
                </div>
                <div class="col-12 col-md-2 mb-3">
                    <button type="button" class="btn btn-primary form-control" data-toggle="modal"
                        data-target="#backgroundAddModal">
                        Add background video
                    </button>
                </div>
            </div>

            <div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="backgrounds">

            </div>
        </div>
    </div>
</main>

<script>
    var keys = [];
    var youtube_urls = [];

    // Show background videos
    $(document).ready(function () {
        $.getJSON("backgrounds.json",
            function (data) {
                delete data["__comment"];
                var background = '';
                $.each(data, function (key, value) {
                    // Add YT urls and keys (for validation)
                    keys.push(key);
                    youtube_urls.push(value[0]);

                    background += '<div class="col">';
                    background += '<div class="card shadow-sm">';
                    background += '<iframe class="bd-placeholder-img card-img-top" width="100%" height="225" src="https://www.youtube-nocookie.com/embed/' + value[0].split("?v=")[1] + '" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>';
                    background += '<div class="card-body">';
                    background += '<p class="card-text">' + value[2] + ' • ' + key + '</p>';
                    background += '<div class="d-flex justify-content-between align-items-center">';
                    background += '<div class="btn-group">';
                    background += '<button type="button" class="btn btn-outline-danger" data-toggle="modal" data-target="#deleteBtnModal" data-background-key="' + key + '">Delete</button>';
                    background += '</div>';
                    background += '</div>';
                    background += '</div>';
                    background += '</div>';
                    background += '</div>';
                });

                $('#backgrounds').append(background);
            });
    });

    // Add background key when deleting
    $('#deleteBtnModal').on('show.bs.modal', function (event) {
        var button = $(event.relatedTarget);
        var key = button.data('background-key');

        $('#background-key').prop('value', key);
    });

    var searchFilter = () => {
        const input = document.querySelector(".searchFilter");
        const cards = document.getElementsByClassName("col");
        console.log(cards[1])
        let filter = input.value
        for (let i = 0; i < cards.length; i++) {
            let title = cards[i].querySelector(".card-text");
            if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
                cards[i].classList.remove("d-none")
            } else {
                cards[i].classList.add("d-none")
            }
        }
    }

    // Validate form
    $("#addBgForm").submit(function (event) {
        $("#addBgForm input").each(function () {
            if (!(validate($(this)))) {
                event.preventDefault();
                event.stopPropagation();
            }
        });
    });

    $('#addBgForm input[type="text"]').on("keyup", function () {
        validate($(this));
    });

    function validate(object) {
        let bool = check(object.prop("name"), object.prop("value"));

        // Change class
        if (bool) {
            object.removeClass("is-invalid");
            object.addClass("is-valid");
        }
        else {
            object.removeClass("is-valid");
            object.addClass("is-invalid");
        }

        return bool;

        // Check values (return true/false)
        function check(name, value) {
            if (name == "youtube_uri") {
                // URI validation
                let regex = /(?:\/|%3D|v=|vi=)([0-9A-z-_]{11})(?:[%#?&]|$)/;
                if (!(regex.test(value))) {
                    $("#feedbackYT").html("Invalid URI");
                    $("#feedbackYT").show();
                    return false;
                }

                // Check if this background already exists
                if (youtube_urls.includes(value)) {
                    $("#feedbackYT").html("This background is already added");
                    $("#feedbackYT").show();
                    return false;
                }

                $("#feedbackYT").hide();
                return true;
            }

            if (name == "filename") {
                // Check if key is already taken
                if (keys.includes(value)) {
                    $("#feedbackFilename").html("This filename is already taken");
                    $("#feedbackFilename").show();
                    return false;
                }

                let regex = /^([a-zA-Z0-9\s_-]{1,100})$/;
                if (!(regex.test(value))) {
                    return false;
                }

                return true;
            }

            if (name == "citation") {
                if (value.trim()) {
                    return true;
                }
            }

            if (name == "position") {
                if (!(value == "center" || value.length == 0 || value % 1 == 0)) {
                    return false;
                }

                return true;
            }
        }
    }
</script>

{% endblock %}
---------------------------------------------
index.html

{% extends "layout.html" %}
{% block main %}

<main>
    <div class="album py-2 bg-light">
        <div class="container">

            <div class="row mt-2">
                <div class="col-12 col-md-3 mb-3">
                    <input type="text" class="form-control searchFilter" placeholder="Search videos"
                        aria-label="Search videos" onkeyup="searchFilter()">
                </div>
            </div>

            <div class="grid row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3" id="videos">

            </div>
        </div>
    </div>
</main>

<script>
    const intervals = [
        { label: 'year', seconds: 31536000 },
        { label: 'month', seconds: 2592000 },
        { label: 'day', seconds: 86400 },
        { label: 'hour', seconds: 3600 },
        { label: 'minute', seconds: 60 },
        { label: 'second', seconds: 1 }
    ];

    function timeSince(date) {
        const seconds = Math.floor((Date.now() / 1000 - date));
        const interval = intervals.find(i => i.seconds < seconds);
        const count = Math.floor(seconds / interval.seconds);
        return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
    }

    $(document).ready(function () {
        $.getJSON("videos.json",
            function (data) {
                data.sort((b, a) => a['time'] - b['time'])
                var video = '';
                $.each(data, function (key, value) {
                    video += '<div class="col">';
                    video += '<div class="card shadow-sm">';
                    //keeping original themed image card for future thumbnail usage video += '<svg class="bd-placeholder-img card-img-top" width="100%" height="225" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Placeholder: Thumbnail" preserveAspectRatio="xMidYMid slice" focusable="false"><title>Placeholder</title><rect width="100%" height="100%" fill="#55595c"/><text x="50%" y="50%" fill="#eceeef" dy=".3em">r/'+value.subreddit+'</text></svg>';

                    video += '<div class="card-body">';
                    video += '<p class="card-text">r/' + value.subreddit + ' • ' + checkTitle(value.reddit_title, value.filename) + '</p>';
                    video += '<div class="d-flex justify-content-between align-items-center">';
                    video += '<div class="btn-group">';
                    video += '<a href="https://www.reddit.com/r/' + value.subreddit + '/comments/' + value.id + '/" class="btn btn-sm btn-outline-secondary" target="_blank">View</a>';
                    video += '<a href="http://localhost:4000/results/' + value.subreddit + '/' + value.filename + '" class="btn btn-sm btn-outline-secondary" download>Download</a>';
                    video += '</div>';
                    video += '<div class="btn-group">';
                    video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + getCopyData(value.subreddit, value.reddit_title, value.filename, value.background_credit) + '"><i class="bi bi-card-text"></i></button>';
                    video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + checkTitle(value.reddit_title, value.filename) + ' #Shorts #reddit"><i class="bi bi-youtube"></i></button>';
                    video += '<button type="button" data-toggle="tooltip" id="copy" data-original-title="Copy to clipboard" class="btn btn-sm btn-outline-secondary" data-clipboard-text="' + checkTitle(value.reddit_title, value.filename) + ' #reddit"><i class="bi bi-instagram"></i></button>';
                    video += '</div>';
                    video += '<small class="text-muted">' + timeSince(value.time) + '</small>';
                    video += '</div>';
                    video += '</div>';
                    video += '</div>';
                    video += '</div>';

                });

                $('#videos').append(video);
            });
    });

    $(document).ready(function () {
        $('[data-toggle="tooltip"]').tooltip();
        $('[data-toggle="tooltip"]').on('click', function () {
            $(this).tooltip('hide');
        });
    });

    $('#copy').tooltip({
        trigger: 'click',
        placement: 'bottom'
    });

    function setTooltip(btn, message) {
        $(btn).tooltip('hide')
            .attr('data-original-title', message)
            .tooltip('show');
    }

    function hoverTooltip(btn, message) {
        $(btn).tooltip('hide')
            .attr('data-original-title', message)
            .tooltip('show');
    }

    function hideTooltip(btn) {
        setTimeout(function () {
            $(btn).tooltip('hide');
        }, 1000);
    }

    function disposeTooltip(btn) {
        setTimeout(function () {
            $(btn).tooltip('dispose');
        }, 1500);
    }

    var clipboard = new ClipboardJS('#copy');

    clipboard.on('success', function (e) {
        e.clearSelection();
        console.info('Action:', e.action);
        console.info('Text:', e.text);
        console.info('Trigger:', e.trigger);
        setTooltip(e.trigger, 'Copied!');
        hideTooltip(e.trigger);
        disposeTooltip(e.trigger);
    });

    clipboard.on('error', function (e) {
        console.error('Action:', e.action);
        console.error('Trigger:', e.trigger);
        setTooltip(e.trigger, fallbackMessage(e.action));
        hideTooltip(e.trigger);
    });

    function getCopyData(subreddit, reddit_title, filename, background_credit) {

        if (subreddit == undefined) {
            subredditCopy = "";
        } else {
            subredditCopy = "r/" + subreddit + "\n\n";
        }

        const file = filename.slice(0, -4);
        if (reddit_title == file) {
            titleCopy = reddit_title;
        } else {
            titleCopy = file;
        }

        var copyData = "";
        copyData += subredditCopy;
        copyData += titleCopy;
        copyData += "\n\nBackground credit: " + background_credit;
        return copyData;
    }

    function getLink(subreddit, id, reddit_title) {
        if (subreddit == undefined) {
            return reddit_title;
        } else {
            return "<a target='_blank' href='https://www.reddit.com/r/" + subreddit + "/comments/" + id + "/'>" + reddit_title + "</a>";
        }
    }

    function checkTitle(reddit_title, filename) {
        const file = filename.slice(0, -4);
        if (reddit_title == file) {
            return reddit_title;
        } else {
            return file;
        }
    }

    var searchFilter = () => {
        const input = document.querySelector(".searchFilter");
        const cards = document.getElementsByClassName("col");
        console.log(cards[1])
        let filter = input.value
        for (let i = 0; i < cards.length; i++) {
            let title = cards[i].querySelector(".card-text");
            if (title.innerText.toLowerCase().indexOf(filter.toLowerCase()) > -1) {
                cards[i].classList.remove("d-none")
            } else {
                cards[i].classList.add("d-none")
            }
        }
    }
</script>
{% endblock %}
-------------------------------------

layout.html

<html lang="en">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta http-equiv="cache-control" content="no-cache" />
    <title>RedditVideoMakerBot</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/css/bootstrap.min.css"
        integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
    <link href="https://getbootstrap.com/docs/5.2/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">

    <style>
        .bd-placeholder-img {
            font-size: 1.125rem;
            text-anchor: middle;
            -webkit-user-select: none;
            -moz-user-select: none;
            user-select: none;
        }

        .feedback-invalid {
            color: #dc3545;
        }

        @media (min-width: 768px) {
            .bd-placeholder-img-lg {
                font-size: 3.5rem;
            }
        }

        .bi {
            vertical-align: -.125em;
            fill: currentColor;
        }

        .nav {
            display: flex;
            flex-wrap: nowrap;
            padding-bottom: 1rem;
            margin-top: -1px;
            overflow-x: auto;
            text-align: center;
            white-space: nowrap;
            -webkit-overflow-scrolling: touch;
        }

        #tooltip {
            background-color: #333;
            color: white;
            padding: 5px 10px;
            border-radius: 4px;
            font-size: 13px;
        }

        .tooltip-inner {
            max-width: 500px !important;
        }
        #hard-reload {
            cursor: pointer;
            color: darkblue;
        }
        #hard-reload:hover {
            color: blue;
        }
    </style>
</head>

<script src="https://code.jquery.com/jquery-3.1.1.js" integrity="sha256-16cdPddA6VdVInumRGo6IbivbERE8p7CQR3HzTBuELA="
    crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.3/dist/umd/popper.min.js"
    integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
    crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/js/bootstrap.min.js"
    integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
    crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
<script src="https://unpkg.com/isotope-layout@3/dist/isotope.pkgd.js"></script>

<body>
    <header>
        {% if get_flashed_messages() %}
        {% for category, message in get_flashed_messages(with_categories=true) %}

        {% if category == "error" %}
        <div class="alert alert-danger mb-0 text-center" role="alert">
            {{ message }}
        </div>

        {% else %}
        <div class="alert alert-success mb-0 text-center" role="alert">
            {{ message }}
        </div>
        {% endif %}
        {% endfor %}
        {% endif %}
        <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
            <div class="container">
                <a href="/" class="navbar-brand d-flex align-items-center">
                    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor"
                        stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="me-2"
                        viewBox="0 0 24 24">
                        <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
                        <circle cx="12" cy="13" r="4" />
                    </svg>
                    <strong>RedditVideoMakerBot</strong>
                </a>

                <div class="collapse navbar-collapse">
                    <ul class="navbar-nav mr-auto">
                        <li class="nav-item">
                            <a class="nav-link" href="backgrounds">Background Manager</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" href="settings">Settings</a>
                        </li>
                    </ul>
                    <!-- Future feature
                    <ul class="navbar-nav">
                        <li class="nav-item">
                            <button class="btn btn-outline-success mr-auto mt-2 mt-lg-0">Create new short</button>
                        </li>
                    </ul>
                    -->
                </div>
            </div>
        </nav>
    </header>

    {% block main %}{% endblock %}

    <footer class="text-muted py-5">
        <div class="container">
            <p class="float-end mb-1">
                <a href="#">Back to top</a>
            </p>
            <p class="mb-1"><a href="https://getbootstrap.com/docs/5.2/examples/album/" target="_blank">Album</a>
                Example
                Theme by &copy; Bootstrap. <a
                    href="https://github.com/elebumm/RedditVideoMakerBot/blob/master/README.md#developers-and-maintainers"
                    target="_blank">Developers and Maintainers</a></p>
            <p class="mb-0">If your data is not refreshing, try to hard reload(Ctrl + F5) or click <a id="hard-reload">this</a> and visit your local

                <strong>{{ file }}</strong> file.
            </p>
        </div>
    </footer>
    <script>
        document.getElementById("hard-reload").addEventListener("click", function () {
            window.location.reload(true);
        });
    </script>
</body>

</html>
-----------------------------------
settings.html

{% extends "layout.html" %}
{% block main %}

<main>
    <br>
    <div class="container">
        <form id="settingsForm" action="/settings" method="post" novalidate>

            <!-- Reddit Credentials -->
            <p class="h4">Reddit Credentials</p>
            <div class="row mb-2">
                <label for="client_id" class="col-4">Client ID</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-person"></i>
                        </div>
                        <input name="client_id" value="{{ data.client_id }}" placeholder="Your Reddit app's client ID"
                            type="text" class="form-control" data-toggle="tooltip"
                            data-original-title='Text under "personal use script" on www.reddit.com/prefs/apps'>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="client_secret" class="col-4">Client Secret</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-key-fill"></i>
                        </div>
                        <input name="client_secret" value="{{ data.client_secret }}"
                            placeholder="Your Reddit app's client secret" type="text" class="form-control"
                            data-toggle="tooltip" data-original-title='"Secret" on www.reddit.com/prefs/apps'>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="username" class="col-4">Reddit Username</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-person-fill"></i>
                        </div>
                        <input name="username" value="{{ data.username }}" placeholder="Your Reddit account's username"
                            type="text" class="form-control">
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="password" class="col-4">Reddit Password</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-lock-fill"></i>
                        </div>
                        <input name="password" value="{{ data.password }}" placeholder="Your Reddit account's password"
                            type="password" class="form-control">
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label class="col-4">Do you have Reddit 2FA enabled?</label>
                <div class="col-8">
                    <div class="form-check form-switch">
                        <input name="2fa" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip"
                            data-original-title='Check it if you have enabled 2FA on your Reddit account'>
                    </div>
                    <span class="form-text text-muted"><a
                            href="https://reddit-video-maker-bot.netlify.app/docs/configuring#setting-up-the-api"
                            target="_blank">Need help? Click here to open step-by-step guide.</a></span>
                </div>
            </div>

            <!-- Reddit Thread -->
            <p class="h4">Reddit Thread</p>
            <div class="row mb-2">
                <label class="col-4">Random Thread</label>
                <div class="col-8">
                    <div class="form-check form-switch">
                        <input name="random" class="form-check-input" type="checkbox" value="True" data-toggle="tooltip"
                            data-original-title='If disabled, it will ask you for a thread link, instead of picking random one'>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="subreddit" class="col-4">Subreddit</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-reddit"></i>
                        </div>
                        <input value="{{ data.subreddit }}" name="subreddit" type="text" class="form-control"
                            placeholder="Subreddit to pull posts from (e.g. AskReddit)" data-toggle="tooltip"
                            data-original-title='You can have multiple subreddits,
                                    add "+" between them (e.g. AskReddit+Redditdev)'>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="post_id" class="col-4">Post ID</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-file-text"></i>
                        </div>
                        <input value="{{ data.post_id }}" name="post_id" type="text" class="form-control"
                            placeholder="Used if you want to use a specific post (e.g. urdtfx)">
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="max_comment_length" class="col-4">Max Comment Length</label>
                <div class="col-8">
                    <div class="input-group">
                        <input name="max_comment_length" type="range" class="form-range" min="10" max="10000" step="1"
                            value="{{ data.max_comment_length }}" data-toggle="tooltip"
                            data-original-title="{{ data.max_comment_length }}">
                    </div>
                    <span class="form-text text-muted">Max number of characters a comment can have.</span>
                </div>
            </div>
            <div class="row mb-2">
                <label for="post_lang" class="col-4">Post Language</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-translate"></i>
                        </div>
                        <input value="{{ data.post_lang }}" name="post_lang" type="text" class="form-control"
                            placeholder="The language you would like to translate to">
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="min_comments" class="col-4">Minimum Comments</label>
                <div class="col-8">
                    <div class="input-group">
                        <input name="min_comments" type="range" class="form-range" min="15" max="1000" step="1"
                            value="{{ data.min_comments }}" data-toggle="tooltip"
                            data-original-title="{{ data.min_comments }}">
                    </div>
                    <span class="form-text text-muted">The minimum number of comments a post should have to be
                        included.</span>
                </div>
            </div>

            <!-- General Settings -->
            <p class="h4">General Settings</p>
            <div class="row mb-2">
                <label class="col-4">Allow NSFW</label>
                <div class="col-8">
                    <div class="form-check form-switch">
                        <input name="allow_nsfw" class="form-check-input" type="checkbox" value="True"
                            data-toggle="tooltip" data-original-title='If checked NSFW posts will be allowed'>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="theme" class="col-4">Reddit Theme</label>
                <div class="col-8">
                    <select name="theme" class="form-select" data-toggle="tooltip"
                        data-original-title='Sets the theme of Reddit screenshots'>
                        <option value="dark">Dark</option>
                        <option value="light">Light</option>
                    </select>
                </div>
            </div>
            <div class="row mb-2">
                <label for="times_to_run" class="col-4">Times To Run</label>
                <div class="col-8">
                    <div class="input-group">
                        <input name="times_to_run" type="range" class="form-range" min="1" max="1000" step="1"
                            value="{{ data.times_to_run }}" data-toggle="tooltip"
                            data-original-title="{{ data.times_to_run }}">
                    </div>
                    <span class="form-text text-muted">Used if you want to create multiple videos.</span>
                </div>
            </div>
            <div class="row mb-2">
                <label for="opacity" class="col-4">Opacity Of Comments</label>
                <div class="col-8">
                    <div class="input-group">
                        <input name="opacity" type="range" class="form-range" min="0" max="1" step="0.05"
                            value="{{ data.opacity }}" data-toggle="tooltip" data-original-title="{{ data.opacity }}">
                    </div>
                    <span class="form-text text-muted">Sets the opacity of the comments when overlayed over the
                        background.</span>
                </div>
            </div>
            <div class="row mb-2">
                <label for="transition" class="col-4">Transition</label>
                <div class="col-8">
                    <div class="input-group">
                        <input name="transition" type="range" class="form-range" min="0" max="2" step="0.05"
                            value="{{ data.transition }}" data-toggle="tooltip"
                            data-original-title="{{ data.transition }}">
                    </div>
                    <span class="form-text text-muted">Sets the transition time (in seconds) between the
                        comments. Set to 0 if you want to disable it.</span>
                </div>
            </div>
            <div class="row mb-2">
                <label for="background_choice" class="col-4">Background Choice</label>
                <div class="col-8">
                    <select name="background_choice" class="form-select" data-toggle="tooltip"
                        data-original-title='Sets the background of the video'>
                        <option value=" ">Random Video</option>
                        {% for background in checks["background_video"]["options"][1:] %}
                        <option value="{{background}}">{{background}}</option>
                        {% endfor %}
                    </select>
                    <span class="form-text text-muted"><a href="/backgrounds" target="_blank">See all available
                            backgrounds</a></span>
                </div>
            </div>
            <div class="row mb-2">
                <label for="background_thumbnail" class="col-4">Background Thumbnail</label>
                <div class="col-8">
                    <div class="form-check form-switch">
                        <input name="background_thumbnail" class="form-check-input" type="checkbox" value="True"
                            data-toggle="tooltip"
                            data-original-title='If checked a thumbnail will be added to the video (put a thumbnail.png file in the assets/backgrounds directory for it to be used.)'>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="background_thumbnail_font_family" class="col-4">Background Thumbnail Font Family (.ttf)</label>
                <div class="col-8">
                    <input name="background_thumbnail_font_family" type="text" class="form-control"
                        placeholder="arial" value="{{ data.background_thumbnail_font_family }}">
                </div>
            </div>
            <div class="row mb-2">
                <label for="background_thumbnail_font_size" class="col-4">Background Thumbnail Font Size (px)</label>
                <div class="col-8">
                    <input name="background_thumbnail_font_size" type="number" class="form-control"
                        placeholder="96" value="{{ data.background_thumbnail_font_size }}">
                </div>
            </div>
            <!-- need to create a color picker -->
            <div class="row mb-2">
                <label for="background_thumbnail_font_color" class="col-4">Background Thumbnail Font Color (rgb)</label>
                <div class="col-8">
                    <input name="background_thumbnail_font_color" type="text" class="form-control"
                        placeholder="255,255,255" value="{{ data.background_thumbnail_font_color }}">
                </div>
            </div>

            <!-- TTS Settings -->
            <p class="h4">TTS Settings</p>
            <div class="row mb-2">
                <label for="voice_choice" class="col-4">TTS Voice Choice</label>
                <div class="col-8">
                    <select name="voice_choice" class="form-select" data-toggle="tooltip"
                        data-original-title='The voice platform used for TTS generation'>
                        <option value="streamlabspolly">Streamlabspolly</option>
                        <option value="tiktok">TikTok</option>
                        <option value="googletranslate">Google Translate</option>
                        <option value="awspolly">AWS Polly</option>
                        <option value="pyttsx">Python TTS (pyttsx)</option>
                    </select>
                </div>
            </div>
            <div class="row mb-2">
                <label for="aws_polly_voice" class="col-4">AWS Polly Voice</label>
                <div class="col-8">
                    <div class="input-group voices">
                        <select name="aws_polly_voice" class="form-select" data-toggle="tooltip"
                            data-original-title='The voice used for AWS Polly'>
                            <option value="Brian">Brian</option>
                            <option value="Emma">Emma</option>
                            <option value="Russell">Russell</option>
                            <option value="Joey">Joey</option>
                            <option value="Matthew">Matthew</option>
                            <option value="Joanna">Joanna</option>
                            <option value="Kimberly">Kimberly</option>
                            <option value="Amy">Amy</option>
                            <option value="Geraint">Geraint</option>
                            <option value="Nicole">Nicole</option>
                            <option value="Justin">Justin</option>
                            <option value="Ivy">Ivy</option>
                            <option value="Kendra">Kendra</option>
                            <option value="Salli">Salli</option>
                            <option value="Raveena">Raveena</option>
                        </select>

                        <button type="button" class="btn btn-primary"><i id="awspolly_icon"
                                class="bi-volume-up-fill"></i></button>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="streamlabs_polly_voice" class="col-4">Streamlabs Polly Voice</label>
                <div class="col-8">
                    <div class="input-group voices">
                        <select id="streamlabs_polly_voice" name="streamlabs_polly_voice" class="form-select"
                            data-toggle="tooltip" data-original-title='The voice used for Streamlabs Polly'>
                            <option value="Brian">Brian</option>
                            <option value="Emma">Emma</option>
                            <option value="Russell">Russell</option>
                            <option value="Joey">Joey</option>
                            <option value="Matthew">Matthew</option>
                            <option value="Joanna">Joanna</option>
                            <option value="Kimberly">Kimberly</option>
                            <option value="Amy">Amy</option>
                            <option value="Geraint">Geraint</option>
                            <option value="Nicole">Nicole</option>
                            <option value="Justin">Justin</option>
                            <option value="Ivy">Ivy</option>
                            <option value="Kendra">Kendra</option>
                            <option value="Salli">Salli</option>
                            <option value="Raveena">Raveena</option>
                        </select>

                        <button type="button" class="btn btn-primary"><i id="streamlabs_icon"
                                class="bi bi-volume-up-fill"></i></button>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="tiktok_voice" class="col-4">TikTok Voice</label>
                <div class="col-8">
                    <div class="input-group voices">
                        <select name="tiktok_voice" class="form-select" data-toggle="tooltip"
                            data-original-title='The voice used for TikTok TTS'>
                            <option disabled value="">-----Disney Voices-----</option>
                            <option value="en_us_ghostface">Ghost Face</option>
                            <option value="en_us_chewbacca">Chewbacca</option>
                            <option value="en_us_c3po">C3PO</option>
                            <option value="en_us_stitch">Stitch</option>
                            <option value="en_us_stormtrooper">Stormtrooper</option>
                            <option value="en_us_rocket">Rocket</option>
                            <option disabled value="">-----English Voices-----</option>
                            <option value="en_au_001">English AU - Female</option>
                            <option value="en_au_002">English AU - Male</option>
                            <option value="en_uk_001">English UK - Male 1</option>
                            <option value="en_uk_003">English UK - Male 2</option>
                            <option value="en_us_001">English US - Female (Int. 1)</option>
                            <option value="en_us_002">English US - Female (Int. 2)</option>
                            <option value="en_us_006">English US - Male 1</option>
                            <option value="en_us_007">English US - Male 2</option>
                            <option value="en_us_009">English US - Male 3</option>
                            <option value="en_us_010">English US - Male 4</option>
                            <option disabled value="">-----European Voices-----</option>
                            <option value="fr_001">French - Male 1</option>
                            <option value="fr_002">French - Male 2</option>
                            <option value="de_001">German - Female</option>
                            <option value="de_002">German - Male</option>
                            <option value="es_002">Spanish - Male</option>
                            <option disabled value="">-----American Voices-----</option>
                            <option value="es_mx_002">Spanish MX - Male</option>
                            <option value="br_001">Portuguese BR - Female 1</option>
                            <option value="br_003">Portuguese BR - Female 2</option>
                            <option value="br_004">Portuguese BR - Female 3</option>
                            <option value="br_005">Portuguese BR - Male</option>
                            <option disabled value="">-----Asian Voices-----</option>
                            <option value="id_001">Indonesian - Female</option>
                            <option value="jp_001">Japanese - Female 1</option>
                            <option value="jp_003">Japanese - Female 2</option>
                            <option value="jp_005">Japanese - Female 3</option>
                            <option value="jp_006">Japanese - Male</option>
                            <option value="kr_002">Korean - Male 1</option>
                            <option value="kr_003">Korean - Female</option>
                            <option value="kr_004">Korean - Male 2</option>
                        </select>

                        <button type="button" class="btn btn-primary"><i id="tiktok_icon"
                                class="bi-volume-up-fill"></i></button>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="tiktok_sessionid" class="col-4">TikTok SessionId</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-mic-fill"></i>
                        </div>
                        <input value="{{ data.tiktok_sessionid }}" name="tiktok_sessionid" type="text" class="form-control"
                            data-toggle="tooltip"
                            data-original-title="TikTok sessionid needed for the TTS API request. Check documentation if you don't know how to obtain it.">
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="python_voice" class="col-4">Python Voice</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-mic-fill"></i>
                        </div>
                        <input value="{{ data.python_voice }}" name="python_voice" type="text" class="form-control"
                            data-toggle="tooltip"
                            data-original-title='The index of the system TTS voices (can be downloaded externally, run ptt.py to find value, start from zero)'>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="py_voice_num" class="col-4">Py Voice Number</label>
                <div class="col-8">
                    <div class="input-group">
                        <div class="input-group-text">
                            <i class="bi bi-headset"></i>
                        </div>
                        <input value="{{ data.py_voice_num }}" name="py_voice_num" type="text" class="form-control"
                            data-toggle="tooltip"
                            data-original-title='The number of system voices (2 are pre-installed in Windows)'>
                    </div>
                </div>
            </div>
            <div class="row mb-2">
                <label for="silence_duration" class="col-4">Silence Duration</label>
                <div class="col-8">
                    <div class="input-group">
                        <input name="silence_duration" type="range" class="form-range" min="0" max="5" step="0.05"
                            value="{{ data.silence_duration }}" data-toggle="tooltip"
                            data-original-title="{{ data.silence_duration }}">
                    </div>
                    <span class="form-text text-muted">Time in seconds between TTS comments.</span>
                </div>
            </div>
            <div class="col text-center">
                <br>
                <button id="defaultSettingsBtn" type="button" class="btn btn-secondary">Default
                    Settings</button>
                <button id="submitButton" type="submit" class="btn btn-success">Save
                    Changes</button>
            </div>
        </form>
    </div>
    <audio src=""></audio>
</main>

<script>
    // Test voices buttons
    var playing = false;

    $(".voices button").click(function () {
        var icon = $(this).find("i");
        var audio = $("audio");

        if (playing) {
            playing.toggleClass("bi-volume-up-fill bi-stop-fill");

            // Clicked the same button - stop audio
            if (playing.prop("id") == icon.prop("id")) {
                audio[0].pause();
                playing = false;
                return;
            }
        }

        icon.toggleClass("bi-volume-up-fill bi-stop-fill");
        let path = "voices/" + $(this).closest(".voices").find("select").prop("value").toLowerCase() + ".mp3";

        audio.prop("src", path);
        audio[0].play();
        playing = icon;

        audio[0].onended = function () {
            icon.toggleClass("bi-volume-up-fill bi-stop-fill");
            playing = false;
        }
    });

    // Wait for DOM to load
    $(document).ready(function () {
        // Add tooltips
        $('[data-toggle="tooltip"]').tooltip();
        $('[data-toggle="tooltip"]').on('click', function () {
            $(this).tooltip('hide');
        });

        // Update slider tooltip
        $(".form-range").on("input", function () {
            $(this).attr("value", $(this).val());
            $(this).attr("data-original-title", $(this).val());
            $(this).tooltip("show");
        });

        // Get current config
        var data = JSON.parse('{{data | tojson}}');

        // Set current checkboxes
        $('.form-check-input').each(function () {
            $(this).prop("checked", data[$(this).prop("name")]);
        });

        // Set current select options
        $('.form-select').each(function () {
            $(this).prop("value", data[$(this).prop("name")]);
        });

        // Submit "False" when checkbox isn't ticked
        $('#settingsForm').submit(function () {
            $('.form-check-input').each(function () {
                if (!($(this).is(':checked'))) {
                    $(this).prop("value", "False");
                    $(this).prop("checked", true);
                }
            });
        });


        // Get validation values
        let validateChecks = JSON.parse('{{checks | tojson}}');

        // Set default values
        $("#defaultSettingsBtn").click(function (event) {
            $("#settingsForm input, #settingsForm select").each(function () {
                let check = validateChecks[$(this).prop("name")];

                if (check["default"]) {
                    $(this).prop("value", check["default"]);

                    // Update tooltip value for input[type="range"]
                    if ($(this).prop("type") == "range") {
                        $(this).attr("data-original-title", check["default"]);
                    }
                }
            });
        });

        // Validate form
        $('#settingsForm').submit(function (event) {
            $("#settingsForm input, #settingsForm select").each(function () {
                if (!(validate($(this)))) {
                    event.preventDefault();
                    event.stopPropagation();

                    $("html, body").animate({
                        scrollTop: $(this).offset().top
                    });
                }

            });
        });

        $("#settingsForm input").on("keyup", function () {
            validate($(this));
        });

        $("#settingsForm select").on("change", function () {
            validate($(this));
        });

        function validate(object) {
            let bool = check(object.prop("name"), object.prop("value"));

            // Change class
            if (bool) {
                object.removeClass("is-invalid");
                object.addClass("is-valid");
            }
            else {
                object.removeClass("is-valid");
                object.addClass("is-invalid");
            }

            return bool;

            // Check values (return true/false)
            function check(name, value) {
                let check = validateChecks[name];

                // If value is empty - check if it's optional
                if (value.length == 0) {
                    if (check["optional"] == false) {
                        return false;
                    }
                    else {
                        object.prop("value", check["default"]);
                        return true;
                    }
                }

                // Check if value is too short
                if (check["nmin"]) {
                    if (check["type"] == "int" || check["type"] == "float") {
                        if (value < check["nmin"]) {
                            return false;
                        }
                    }
                    else {
                        if (value.length < check["nmin"]) {
                            return false;
                        }

                    }
                }

                // Check if value is too long
                if (check["nmax"]) {
                    if (check["type"] == "int" || check["type"] == "float") {
                        if (value > check["nmax"]) {
                            return false;
                        }
                    }
                    else {
                        if (value.length > check["nmax"]) {
                            return false;
                        }

                    }
                }

                // Check if value matches regex
                if (check["regex"]) {
                    let regex = new RegExp(check["regex"]);
                    if (!(regex.test(value))) {
                        return false;
                    }
                }

                return true;
            }
        }
    });
</script>

{% endblock %}
----------------------------------------
logs
-script_logs.txt
---------------------------------
reddit
-subreddit.py

import re

import praw
from praw.models import MoreComments
from prawcore.exceptions import ResponseException

from utils import settings
from utils.ai_methods import sort_by_similarity
from utils.console import print_step, print_substep
from utils.posttextparser import posttextparser
from utils.subreddit import get_subreddit_undone
from utils.videos import check_done
from utils.voice import sanitize_text


def get_subreddit_threads(POST_ID: str):
    """
    Returns a list of threads from the AskReddit subreddit.
    """

    print_substep("Logging into Reddit.")

    content = {}
    if settings.config["reddit"]["creds"]["2fa"]:
        print("\nEnter your two-factor authentication code from your authenticator app.\n")
        code = input("> ")
        print()
        pw = settings.config["reddit"]["creds"]["password"]
        passkey = f"{pw}:{code}"
    else:
        passkey = settings.config["reddit"]["creds"]["password"]
    username = settings.config["reddit"]["creds"]["username"]
    if str(username).casefold().startswith("u/"):
        username = username[2:]
    try:
        reddit = praw.Reddit(
            client_id=settings.config["reddit"]["creds"]["client_id"],
            client_secret=settings.config["reddit"]["creds"]["client_secret"],
            user_agent="Accessing Reddit threads",
            username=username,
            passkey=passkey,
            check_for_async=False,
        )
    except ResponseException as e:
        if e.response.status_code == 401:
            print("Invalid credentials - please check them in config.toml")
    except:
        print("Something went wrong...")

    # Ask user for subreddit input
    print_step("Getting subreddit threads...")
    similarity_score = 0
    if not settings.config["reddit"]["thread"][
        "subreddit"
    ]:  # note to user. you can have multiple subreddits via reddit.subreddit("redditdev+learnpython")
        try:
            subreddit = reddit.subreddit(
                re.sub(r"r\/", "", input("What subreddit would you like to pull from? "))
                # removes the r/ from the input
            )
        except ValueError:
            subreddit = reddit.subreddit("askreddit")
            print_substep("Subreddit not defined. Using AskReddit.")
    else:
        sub = settings.config["reddit"]["thread"]["subreddit"]
        print_substep(f"Using subreddit: r/{sub} from TOML config")
        subreddit_choice = sub
        if str(subreddit_choice).casefold().startswith("r/"):  # removes the r/ from the input
            subreddit_choice = subreddit_choice[2:]
        subreddit = reddit.subreddit(subreddit_choice)

    if POST_ID:  # would only be called if there are multiple queued posts
        submission = reddit.submission(id=POST_ID)

    elif (
        settings.config["reddit"]["thread"]["post_id"]
        and len(str(settings.config["reddit"]["thread"]["post_id"]).split("+")) == 1
    ):
        submission = reddit.submission(id=settings.config["reddit"]["thread"]["post_id"])
    elif settings.config["ai"]["ai_similarity_enabled"]:  # ai sorting based on comparison
        threads = subreddit.hot(limit=50)
        keywords = settings.config["ai"]["ai_similarity_keywords"].split(",")
        keywords = [keyword.strip() for keyword in keywords]
        # Reformat the keywords for printing
        keywords_print = ", ".join(keywords)
        print(f"Sorting threads by similarity to the given keywords: {keywords_print}")
        threads, similarity_scores = sort_by_similarity(threads, keywords)
        submission, similarity_score = get_subreddit_undone(
            threads, subreddit, similarity_scores=similarity_scores
        )
    else:
        threads = subreddit.hot(limit=25)
        submission = get_subreddit_undone(threads, subreddit)

    if submission is None:
        return get_subreddit_threads(POST_ID)  # submission already done. rerun

    elif not submission.num_comments and settings.config["settings"]["storymode"] == "false":
        print_substep("No comments found. Skipping.")
        exit()

    submission = check_done(submission)  # double-checking

    upvotes = submission.score
    ratio = submission.upvote_ratio * 100
    num_comments = submission.num_comments
    threadurl = f"https://new.reddit.com/{submission.permalink}"

    print_substep(f"Video will be: {submission.title} :thumbsup:", style="bold green")
    print_substep(f"Thread url is: {threadurl} :thumbsup:", style="bold green")
    print_substep(f"Thread has {upvotes} upvotes", style="bold blue")
    print_substep(f"Thread has a upvote ratio of {ratio}%", style="bold blue")
    print_substep(f"Thread has {num_comments} comments", style="bold blue")
    if similarity_score:
        print_substep(
            f"Thread has a similarity score up to {round(similarity_score * 100)}%",
            style="bold blue",
        )

    content["thread_url"] = threadurl
    content["thread_title"] = submission.title
    content["thread_id"] = submission.id
    content["is_nsfw"] = submission.over_18
    content["comments"] = []
    if settings.config["settings"]["storymode"]:
        if settings.config["settings"]["storymodemethod"] == 1:
            content["thread_post"] = posttextparser(submission.selftext)
        else:
            content["thread_post"] = submission.selftext
    else:
        for top_level_comment in submission.comments:
            if isinstance(top_level_comment, MoreComments):
                continue

            if top_level_comment.body in ["[removed]", "[deleted]"]:
                continue  # # see https://github.com/JasonLovesDoggo/RedditVideoMakerBot/issues/78
            if not top_level_comment.stickied:
                sanitised = sanitize_text(top_level_comment.body)
                if not sanitised or sanitised == " ":
                    continue
                if len(top_level_comment.body) <= int(
                    settings.config["reddit"]["thread"]["max_comment_length"]
                ):
                    if len(top_level_comment.body) >= int(
                        settings.config["reddit"]["thread"]["min_comment_length"]
                    ):
                        if (
                            top_level_comment.author is not None
                            and sanitize_text(top_level_comment.body) is not None
                        ):  # if errors occur with this change to if not.
                            content["comments"].append(
                                {
                                    "comment_body": top_level_comment.body,
                                    "comment_url": top_level_comment.permalink,
                                    "comment_id": top_level_comment.id,
                                }
                            )

    print_substep("Received subreddit threads Successfully.", style="bold green")
    return content
---------------------------------------------
TTS
_init_.py
----------------------------------------------
aws_polly.py

import random
import sys

from boto3 import Session
from botocore.exceptions import BotoCoreError, ClientError, ProfileNotFound

from utils import settings

voices = [
    "Brian",
    "Emma",
    "Russell",
    "Joey",
    "Matthew",
    "Joanna",
    "Kimberly",
    "Amy",
    "Geraint",
    "Nicole",
    "Justin",
    "Ivy",
    "Kendra",
    "Salli",
    "Raveena",
]


class AWSPolly:
    def __init__(self):
        self.max_chars = 3000
        self.voices = voices

    def run(self, text, filepath, random_voice: bool = False):
        try:
            session = Session(profile_name="polly")
            polly = session.client("polly")
            if random_voice:
                voice = self.randomvoice()
            else:
                if not settings.config["settings"]["tts"]["aws_polly_voice"]:
                    raise ValueError(
                        f"Please set the TOML variable AWS_VOICE to a valid voice. options are: {voices}"
                    )
                voice = str(settings.config["settings"]["tts"]["aws_polly_voice"]).capitalize()
            try:
                # Request speech synthesis
                response = polly.synthesize_speech(
                    Text=text, OutputFormat="mp3", VoiceId=voice, Engine="neural"
                )
            except (BotoCoreError, ClientError) as error:
                # The service returned an error, exit gracefully
                print(error)
                sys.exit(-1)

            # Access the audio stream from the response
            if "AudioStream" in response:
                file = open(filepath, "wb")
                file.write(response["AudioStream"].read())
                file.close()
                # print_substep(f"Saved Text {idx} to MP3 files successfully.", style="bold green")

            else:
                # The response didn't contain audio data, exit gracefully
                print("Could not stream audio")
                sys.exit(-1)
        except ProfileNotFound:
            print("You need to install the AWS CLI and configure your profile")
            print(
                """
            Linux: https://docs.aws.amazon.com/polly/latest/dg/setup-aws-cli.html
            Windows: https://docs.aws.amazon.com/polly/latest/dg/install-voice-plugin2.html
            """
            )
            sys.exit(-1)

    def randomvoice(self):
        return random.choice(self.voices)
-----------------------------------------------------
elevenlabs.py

import random

from elevenlabs import save
from elevenlabs.client import ElevenLabs

from utils import settings


class elevenlabs:
    def __init__(self):
        self.max_chars = 2500
        self.client: ElevenLabs = None

    def run(self, text, filepath, random_voice: bool = False):
        if self.client is None:
            self.initialize()
        if random_voice:
            voice = self.randomvoice()
        else:
            voice = str(settings.config["settings"]["tts"]["elevenlabs_voice_name"]).capitalize()

        audio = self.client.generate(text=text, voice=voice, model="eleven_multilingual_v1")
        save(audio=audio, filename=filepath)

    def initialize(self):
        if settings.config["settings"]["tts"]["elevenlabs_api_key"]:
            api_key = settings.config["settings"]["tts"]["elevenlabs_api_key"]
        else:
            raise ValueError(
                "You didn't set an Elevenlabs API key! Please set the config variable ELEVENLABS_API_KEY to a valid API key."
            )

        self.client = ElevenLabs(api_key=api_key)

    def randomvoice(self):
        if self.client is None:
            self.initialize()
        return random.choice(self.client.voices.get_all().voices).voice_name
----------------------------------------
engine_wrapper.py

import os
import re
from pathlib import Path
from typing import Tuple

import numpy as np
import translators
from moviepy.audio.AudioClip import AudioClip
from moviepy.audio.fx.volumex import volumex
from moviepy.editor import AudioFileClip
from rich.progress import track

from utils import settings
from utils.console import print_step, print_substep
from utils.voice import sanitize_text

DEFAULT_MAX_LENGTH: int = (
    5  # Video length variable, edit this on your own risk. It should work, but it's not supported
)


class TTSEngine:
    """Calls the given TTS engine to reduce code duplication and allow multiple TTS engines.

    Args:
        tts_module            : The TTS module. Your module should handle the TTS itself and saving to the given path under the run method.
        reddit_object         : The reddit object that contains the posts to read.
        path (Optional)       : The unix style path to save the mp3 files to. This must not have leading or trailing slashes.
        max_length (Optional) : The maximum length of the mp3 files in total.

    Notes:
        tts_module must take the arguments text and filepath.
    """

    def __init__(
        self,
        tts_module,
        reddit_object: dict,
        path: str = "assets/temp/",
        max_length: int = DEFAULT_MAX_LENGTH,
        last_clip_length: int = 0,
    ):
        self.tts_module = tts_module()
        self.reddit_object = reddit_object

        self.redditid = re.sub(r"[^\w\s-]", "", reddit_object["thread_id"])
        self.path = path + self.redditid + "/mp3"
        self.max_length = max_length
        self.length = 0
        self.last_clip_length = last_clip_length

    def add_periods(
        self,
    ):  # adds periods to the end of paragraphs (where people often forget to put them) so tts doesn't blend sentences
        for comment in self.reddit_object["comments"]:
            # remove links
            regex_urls = r"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*"
            comment["comment_body"] = re.sub(regex_urls, " ", comment["comment_body"])
            comment["comment_body"] = comment["comment_body"].replace("\n", ". ")
            comment["comment_body"] = re.sub(r"\bAI\b", "A.I", comment["comment_body"])
            comment["comment_body"] = re.sub(r"\bAGI\b", "A.G.I", comment["comment_body"])
            if comment["comment_body"][-1] != ".":
                comment["comment_body"] += "."
            comment["comment_body"] = comment["comment_body"].replace(". . .", ".")
            comment["comment_body"] = comment["comment_body"].replace(".. . ", ".")
            comment["comment_body"] = comment["comment_body"].replace(". . ", ".")
            comment["comment_body"] = re.sub(r'\."\.', '".', comment["comment_body"])

    def run(self) -> Tuple[int, int]:
        Path(self.path).mkdir(parents=True, exist_ok=True)
        print_step("Saving Text to MP3 files...")

        self.add_periods()
        self.call_tts("title", process_text(self.reddit_object["thread_title"]))
        # processed_text = ##self.reddit_object["thread_post"] != ""
        idx = 0

        if settings.config["settings"]["storymode"]:
            if settings.config["settings"]["storymodemethod"] == 0:
                if len(self.reddit_object["thread_post"]) > self.tts_module.max_chars:
                    self.split_post(self.reddit_object["thread_post"], "postaudio")
                else:
                    self.call_tts("postaudio", process_text(self.reddit_object["thread_post"]))
            elif settings.config["settings"]["storymodemethod"] == 1:
                for idx, text in track(enumerate(self.reddit_object["thread_post"])):
                    self.call_tts(f"postaudio-{idx}", process_text(text))

        else:
            for idx, comment in track(enumerate(self.reddit_object["comments"]), "Saving..."):
                # ! Stop creating mp3 files if the length is greater than max length.
                if self.length > self.max_length and idx > 1:
                    self.length -= self.last_clip_length
                    idx -= 1
                    break
                if (
                    len(comment["comment_body"]) > self.tts_module.max_chars
                ):  # Split the comment if it is too long
                    self.split_post(comment["comment_body"], idx)  # Split the comment
                else:  # If the comment is not too long, just call the tts engine
                    self.call_tts(f"{idx}", process_text(comment["comment_body"]))

        print_substep("Saved Text to MP3 files successfully.", style="bold green")
        return self.length, idx

   
    def split_post(self, text: str, idx):
        split_files = []
        
        # Split text into smaller chunks
        split_text = []
        current_chunk = ""
        char_count = 0
        
        # Define splitting points
        split_chars = ['.', ',', '!', '?', ':', ';', '\n']
        max_chunk_size = 45

        for char in text:
            current_chunk += char
            char_count += 1
            
            # Check for split conditions
            if char in split_chars or char_count >= max_chunk_size:
                # If chunk is not empty and not just whitespace
                if current_chunk.strip():
                    split_text.append(current_chunk.strip())
                    print(f"Split chunk: '{current_chunk.strip()}' (length: {len(current_chunk.strip())})")
                current_chunk = ""
                char_count = 0
            
            # Force split if max size is reached without finding a split char
            if char_count == max_chunk_size:
                if current_chunk.strip():
                    split_text.append(current_chunk.strip())
                    print(f"Forced split: '{current_chunk.strip()}' (length: {len(current_chunk.strip())})")
                current_chunk = ""
                char_count = 0

        # Add any remaining text
        if current_chunk.strip():
            split_text.append(current_chunk.strip())
            print(f"Final chunk: '{current_chunk.strip()}' (length: {len(current_chunk.strip())})")

        print(f"Total chunks: {len(split_text)}")

        self.create_silence_mp3()

        idy = None
        for idy, text_cut in enumerate(split_text):
            newtext = process_text(text_cut)
            
            if not newtext or newtext.isspace():
                print(f"Chunk {idy} was blank after processing")
                continue
            else:
                print(f"Processing chunk {idy}: '{newtext}' (length: {len(newtext)})")
                self.call_tts(f"{idx}-{idy}.part", newtext)
                with open(f"{self.path}/list.txt", "a") as f:
                    f.write(f"file '{idx}-{idy}.part.mp3'\n")
                    f.write("file 'silence.mp3'\n")
                split_files.append(str(f"{self.path}/{idx}-{idy}.part.mp3"))

        # Combine all parts into a single MP3
        os.system(
            f"ffmpeg -f concat -y -hide_banner -loglevel panic -safe 0 "
            f"-i {self.path}/list.txt "
            f"-c copy {self.path}/{idx}.mp3"
        )

        # Clean up temporary files
        try:
            for file in split_files:
                os.unlink(file)
            os.unlink(f"{self.path}/list.txt")
        except FileNotFoundError as e:
            print(f"File not found: {e.filename}")
        except OSError as e:
            print(f"OSError: {e}")

        print(f"Finished processing {len(split_files)} audio chunks")


    def call_tts(self, filename: str, text: str):
        self.tts_module.run(
            text,
            filepath=f"{self.path}/{filename}.mp3",
            random_voice=settings.config["settings"]["tts"]["random_voice"],
        )
        # try:
        #     self.length += MP3(f"{self.path}/{filename}.mp3").info.length
        # except (MutagenError, HeaderNotFoundError):
        #     self.length += sox.file_info.duration(f"{self.path}/{filename}.mp3")
        try:
            clip = AudioFileClip(f"{self.path}/{filename}.mp3")
            self.last_clip_length = clip.duration
            self.length += clip.duration
            clip.close()
        except:
            self.length = 0

    def create_silence_mp3(self):
        silence_duration = settings.config["settings"]["tts"]["silence_duration"]
        silence = AudioClip(
            make_frame=lambda t: np.sin(440 * 2 * np.pi * t),
            duration=silence_duration,
            fps=44100,
        )
        silence = volumex(silence, 0)
        silence.write_audiofile(f"{self.path}/silence.mp3", fps=44100, verbose=False, logger=None)


def process_text(text: str, clean: bool = True):
    lang = settings.config["reddit"]["thread"]["post_lang"]
    new_text = sanitize_text(text) if clean else text
    if lang:
        print_substep("Translating Text...")
        translated_text = translators.translate_text(text, translator="google", to_language=lang)
        new_text = sanitize_text(translated_text)
    return new_text
----------------------------
GTTS.py


import random

from gtts import gTTS

from utils import settings


class GTTS:
    def __init__(self):
        self.max_chars = 5000
        self.voices = []

    def run(self, text, filepath):
        tts = gTTS(
            text=text,
            lang=settings.config["reddit"]["thread"]["post_lang"] or "en",
            slow=False,
        )
        tts.save(filepath)

    def randomvoice(self):
        return random.choice(self.voices)
---------------------------------------
pyttsx.py

import random

import pyttsx3

from utils import settings


class pyttsx:
    def __init__(self):
        self.max_chars = 5000
        self.voices = []

    def run(
        self,
        text: str,
        filepath: str,
        random_voice=False,
    ):
        voice_id = settings.config["settings"]["tts"]["python_voice"]
        voice_num = settings.config["settings"]["tts"]["py_voice_num"]
        if voice_id == "" or voice_num == "":
            voice_id = 2
            voice_num = 3
            raise ValueError("set pyttsx values to a valid value, switching to defaults")
        else:
            voice_id = int(voice_id)
            voice_num = int(voice_num)
        for i in range(voice_num):
            self.voices.append(i)
            i = +1
        if random_voice:
            voice_id = self.randomvoice()
        engine = pyttsx3.init()
        voices = engine.getProperty("voices")
        engine.setProperty(
            "voice", voices[voice_id].id
        )  # changing index changes voices but ony 0 and 1 are working here
        engine.save_to_file(text, f"{filepath}")
        engine.runAndWait()

    def randomvoice(self):
        return random.choice(self.voices)
-------------------------
streamlabs_polly.py

import random

import requests
from requests.exceptions import JSONDecodeError

from utils import settings
from utils.voice import check_ratelimit

voices = [
    "Brian",
    "Emma",
    "Russell",
    "Joey",
    "Matthew",
    "Joanna",
    "Kimberly",
    "Amy",
    "Geraint",
    "Nicole",
    "Justin",
    "Ivy",
    "Kendra",
    "Salli",
    "Raveena",
]


# valid voices https://lazypy.ro/tts/


class StreamlabsPolly:
    def __init__(self):
        self.url = "https://streamlabs.com/polly/speak"
        self.max_chars = 550
        self.voices = voices

    def run(self, text, filepath, random_voice: bool = False):
        if random_voice:
            voice = self.randomvoice()
        else:
            if not settings.config["settings"]["tts"]["streamlabs_polly_voice"]:
                raise ValueError(
                    f"Please set the config variable STREAMLABS_POLLY_VOICE to a valid voice. options are: {voices}"
                )
            voice = str(settings.config["settings"]["tts"]["streamlabs_polly_voice"]).capitalize()

        body = {"voice": voice, "text": text, "service": "polly"}
        headers = {"Referer": "https://streamlabs.com/"}
        response = requests.post(self.url, headers=headers, data=body)

        if not check_ratelimit(response):
            self.run(text, filepath, random_voice)

        else:
            try:
                voice_data = requests.get(response.json()["speak_url"])
                with open(filepath, "wb") as f:
                    f.write(voice_data.content)
            except (KeyError, JSONDecodeError):
                try:
                    if response.json()["error"] == "No text specified!":
                        raise ValueError("Please specify a text to convert to speech.")
                except (KeyError, JSONDecodeError):
                    print("Error occurred calling Streamlabs Polly")

    def randomvoice(self):
        return random.choice(self.voices)
------------------------------
TikTok

# documentation for tiktok api: https://github.com/oscie57/tiktok-voice/wiki
import base64
import random
import time
from typing import Final, Optional

import requests

from utils import settings

__all__ = ["TikTok", "TikTokTTSException"]

disney_voices: Final[tuple] = (
    "en_us_ghostface",  # Ghost Face
    "en_us_chewbacca",  # Chewbacca
    "en_us_c3po",  # C3PO
    "en_us_stitch",  # Stitch
    "en_us_stormtrooper",  # Stormtrooper
    "en_us_rocket",  # Rocket
    "en_female_madam_leota",  # Madame Leota
    "en_male_ghosthost",  # Ghost Host
    "en_male_pirate",  # pirate
)

eng_voices: Final[tuple] = (
    "en_au_001",  # English AU - Female
    "en_au_002",  # English AU - Male
    "en_uk_001",  # English UK - Male 1
    "en_uk_003",  # English UK - Male 2
    "en_us_001",  # English US - Female (Int. 1)
    "en_us_002",  # English US - Female (Int. 2)
    "en_us_006",  # English US - Male 1
    "en_us_007",  # English US - Male 2
    "en_us_009",  # English US - Male 3
    "en_us_010",  # English US - Male 4
    "en_male_narration",  # Narrator
    "en_male_funny",  # Funny
    "en_female_emotional",  # Peaceful
    "en_male_cody",  # Serious
)

non_eng_voices: Final[tuple] = (
    # Western European voices
    "fr_001",  # French - Male 1
    "fr_002",  # French - Male 2
    "de_001",  # German - Female
    "de_002",  # German - Male
    "es_002",  # Spanish - Male
    "it_male_m18",  # Italian - Male
    # South american voices
    "es_mx_002",  # Spanish MX - Male
    "br_001",  # Portuguese BR - Female 1
    "br_003",  # Portuguese BR - Female 2
    "br_004",  # Portuguese BR - Female 3
    "br_005",  # Portuguese BR - Male
    # asian voices
    "id_001",  # Indonesian - Female
    "jp_001",  # Japanese - Female 1
    "jp_003",  # Japanese - Female 2
    "jp_005",  # Japanese - Female 3
    "jp_006",  # Japanese - Male
    "kr_002",  # Korean - Male 1
    "kr_003",  # Korean - Female
    "kr_004",  # Korean - Male 2
)

vocals: Final[tuple] = (
    "en_female_f08_salut_damour",  # Alto
    "en_male_m03_lobby",  # Tenor
    "en_male_m03_sunshine_soon",  # Sunshine Soon
    "en_female_f08_warmy_breeze",  # Warmy Breeze
    "en_female_ht_f08_glorious",  # Glorious
    "en_male_sing_funny_it_goes_up",  # It Goes Up
    "en_male_m2_xhxs_m03_silly",  # Chipmunk
    "en_female_ht_f08_wonderful_world",  # Dramatic
)


class TikTok:
    """TikTok Text-to-Speech Wrapper"""

    def __init__(self):
        headers = {
            "User-Agent": "com.zhiliaoapp.musically/2022600030 (Linux; U; Android 7.1.2; es_ES; SM-G988N; "
            "Build/NRD90M;tt-ok/3.12.13.1)",
            "Cookie": f"sessionid={settings.config['settings']['tts']['tiktok_sessionid']}",
        }

        self.URI_BASE = "https://api16-normal-c-useast1a.tiktokv.com/media/api/text/speech/invoke/"
        self.max_chars = 200

        self._session = requests.Session()
        # set the headers to the session, so we don't have to do it for every request
        self._session.headers = headers

    def run(self, text: str, filepath: str, random_voice: bool = False):
        if random_voice:
            voice = self.random_voice()
        else:
            # if tiktok_voice is not set in the config file, then use a random voice
            voice = settings.config["settings"]["tts"].get("tiktok_voice", None)

        # get the audio from the TikTok API
        data = self.get_voices(voice=voice, text=text)

        # check if there was an error in the request
        status_code = data["status_code"]
        if status_code != 0:
            raise TikTokTTSException(status_code, data["message"])

        # decode data from base64 to binary
        try:
            raw_voices = data["data"]["v_str"]
        except:
            print(
                "The TikTok TTS returned an invalid response. Please try again later, and report this bug."
            )
            raise TikTokTTSException(0, "Invalid response")
        decoded_voices = base64.b64decode(raw_voices)

        # write voices to specified filepath
        with open(filepath, "wb") as out:
            out.write(decoded_voices)

    def get_voices(self, text: str, voice: Optional[str] = None) -> dict:
        """If voice is not passed, the API will try to use the most fitting voice"""
        # sanitize text
        text = text.replace("+", "plus").replace("&", "and").replace("r/", "")

        # prepare url request
        params = {"req_text": text, "speaker_map_type": 0, "aid": 1233}

        if voice is not None:
            params["text_speaker"] = voice

        # send request
        try:
            response = self._session.post(self.URI_BASE, params=params)
        except ConnectionError:
            time.sleep(random.randrange(1, 7))
            response = self._session.post(self.URI_BASE, params=params)

        return response.json()

    @staticmethod
    def random_voice() -> str:
        return random.choice(eng_voices)


class TikTokTTSException(Exception):
    def __init__(self, code: int, message: str):
        self._code = code
        self._message = message

    def __str__(self) -> str:
        if self._code == 1:
            return f"Code: {self._code}, reason: probably the aid value isn't correct, message: {self._message}"

        if self._code == 2:
            return f"Code: {self._code}, reason: the text is too long, message: {self._message}"

        if self._code == 4:
            return f"Code: {self._code}, reason: the speaker doesn't exist, message: {self._message}"

        return f"Code: {self._message}, reason: unknown, message: {self._message}"
---------------------------
uploaders
-tiktok_uploader
-tiktok-signature
-javascript
signer
webmssdk.js
xbogus.js
browser.js
index.js
package-lock.json
package.json
utils.js
-----------------------------------------
_init_.py

from .Browser import *
from .cookies import *
from .Config import *
from .Video import *
from .tiktok import *
from .basics import *
------------------------------------
basics.py

import sys

def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)
---------------------------------
bot_utils.py

import requests, secrets, string, uuid, zlib, json, re, time, subprocess
from requests_auth_aws_sigv4 import AWSSigV4


user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"


def subprocess_jsvmp(js, user_agent, url):
	proc = subprocess.Popen(['node', js, url, user_agent], stdout=subprocess.PIPE)
	return proc.stdout.read().decode('utf-8')


def generate_random_string(length, underline):
	characters = (
		string.ascii_letters + string.digits + "_"
		if underline
		else string.ascii_letters + string.digits
	)
	random_string = "".join(secrets.choice(characters) for _ in range(length))
	return random_string


def crc32(content):
	prev = 0
	prev = zlib.crc32(content, prev)
	return ("%X" % (prev & 0xFFFFFFFF)).lower().zfill(8)


def print_response(r):
	print(f"{r.status_code}")
	print(f"{r.content}")


def print_error(url, r):
	print(f"[-] An error occured while reaching {url}")
	print_response(r)


def assert_success(url, r):
	if r.status_code != 200:
		print_error(url, r)
	return r.status_code == 200


def convert_tags(text, session):
	end = 0
	i = -1
	text_extra = []

	def text_extra_block(start, end, type, hashtag_name, user_id, tag_id):
		return {
			"end": end,
			"hashtag_name": hashtag_name,
			"start": start,
			"tag_id": tag_id,
			"type": type,
			"user_id": user_id
		}

	def convert(match):
		nonlocal i, end, text_extra
		i += 1
		if match.group(1):
			text_extra.append(text_extra_block(end, end + len(match.group(1)) + 1, 1, match.group(1), "", str(i)))
			end += len(match.group(1)) + 1
			return "<h id=\"" + str(i) + "\">#" + match.group(1) + "</h>"
		elif match.group(2):
			url = "https://www.tiktok.com/@" + match.group(2)
			headers = {
				'authority': 'www.tiktok.com',
				'accept': '*/*',
				'accept-language': 'q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6,zh;q=0.5,vi;q=0.4',
				'user-agent': user_agent
			}

			r = session.request("GET", url, headers=headers)
			user_id = r.text.split('webapp.user-detail":{"userInfo":{"user":{"id":"')[1].split('"')[0]
			text_extra.append(text_extra_block(end, end + len(match.group(2)) + 1, 0, "", user_id, str(i)))
			end += len(match.group(2)) + 1
			return "<m id=\"" + str(i) + "\">@" + match.group(2) + "</m>"
		else:
			end += len(match.group(3))
			return match.group(3)

	result = re.sub(r'#(\w+)|@([\w.-]+)|([^#@]+)', convert, text)
	return result, text_extra


def printResponse(r):
	print(f"{r }")
	print(f"{r.content }")


def printError(url, r):
	print(f"[-] An error occured while reaching {url}")
	printResponse(r)


def assertSuccess(url, r):
	if r.status_code != 200:
		printError(url, r)
	return r.status_code == 200


def getTagsExtra(title, tags, users, session):
	text_extra = []
	for tag in tags:
		url = "https://www.tiktok.com/api/upload/challenge/sug/"
		params = {"keyword": tag}
		r = session.get(url, params=params)
		if not assertSuccess(url, r):
			return False
		try:
			verified_tag = r.json()["sug_list"][0]["cha_name"]
		except:
			verified_tag = tag
		title += " #"+verified_tag
		text_extra.append({"start": len(title)-len(verified_tag)-1, "end": len(
			title), "user_id": "", "type": 1, "hashtag_name": verified_tag})
	for user in users:
		url = "https://us.tiktok.com/api/upload/search/user/"
		params = {"keyword": user}
		r = session.get(url, params=params)
		if not assertSuccess(url, r):
			return False
		try:
			verified_user = r.json()["user_list"][0]["user_info"]["unique_id"]
			verified_user_id = r.json()["user_list"][0]["user_info"]["uid"]
		except:
			verified_user = user
			verified_user_id = ""
		title += " @"+verified_user
		text_extra.append({"start": len(title)-len(verified_user)-1, "end": len(
			title), "user_id": verified_user_id, "type": 0, "hashtag_name": verified_user})
	return title, text_extra
----------------------------
Browser.py

from .cookies import load_cookies_from_file, save_cookies_to_file
from fake_useragent import UserAgent, FakeUserAgentError
import undetected_chromedriver as uc
import threading, os


WITH_PROXIES = False

class Browser:
    __instance = None

    @staticmethod
    def get():
        # print("Browser.getBrowser() called")
        if Browser.__instance is None:
            with threading.Lock():
                if Browser.__instance is None:
                    # print("Creating new browser instance due to no instance found")
                    Browser.__instance = Browser()
        return Browser.__instance

    def __init__(self):
        if Browser.__instance is not None:
            raise Exception("This class is a singleton!")
        else:
            Browser.__instance = self
        self.user_agent = ""
        options = uc.ChromeOptions()
        # Proxies not supported on login.
        # if WITH_PROXIES:
        #     options.add_argument('--proxy-server={}'.format(PROXIES[0]))
        self._driver = uc.Chrome(options=options)
        self.with_random_user_agent()

    def with_random_user_agent(self, fallback=None):
        """Set random user agent.
        NOTE: This could fail with `FakeUserAgentError`.
        Provide `fallback` str to set the user agent to the provided string, in case it fails. 
        If fallback is not provided the exception is re-raised"""

        try:
            self.user_agent = UserAgent().random
        except FakeUserAgentError as e:
            if fallback:
                self.user_agent = fallback
            else:
                raise e

    @property
    def driver(self):
        return self._driver

    def load_cookies_from_file(self, filename):
        cookies = load_cookies_from_file(filename)
        for cookie in cookies:
            self._driver.add_cookie(cookie)
        self._driver.refresh()

    def save_cookies(self, filename: str, cookies:list=None):
        save_cookies_to_file(cookies, filename)


if __name__ == "__main__":
    import os
    # get current relative path of this file.
    print(os.path.dirname(os.path.abspath(__file__)))
------------------------------------
Config.py

from .basics import eprint


class Config:
    _DEFAULT_OPTIONS = {
        "COOKIES_DIR": "./CookiesDir",
        "VIDEOS_DIR": "./VideosDirPath",
        "POST_PROCESSING_VIDEO_PATH": "./VideosDirPath",
        "IMAGEMAGICK_FONT": "Arial", 
        "IMAGEMAGICK_FONT_SIZE": 80,
        "IMAGEMAGICK_TEXT_FOREGROUND_COLOR": "white",
        "IMAGEMAGICK_TEXT_BACKGROUND_COLOR": "black",
        "TIKTOK_VIDEO_SIZE": (1920, 1080), 
        "TMP_YOUTUBE_VIDEO_DIR": "",
        "LANG": "en", 
        "TIKTOK_BASE_URL": "https://www.tiktok.com/upload?lang=", 
        "IMAGEMAGICK_BINARY": ""
    }

    _EXCLUDE = ["#"]

    _instance = None

    def __init__(self, path=None) -> None:
        if not Config._instance:
            Config._instance = self
            if not path:
                self._options = Config._DEFAULT_OPTIONS
                self.path = None
            else:
                self.path = path
                self._options = {}

    @staticmethod
    def get():
        if not Config._instance:
            Config._instance = Config()
        
        return Config._instance
    
    @staticmethod
    def load(path: str):
        config = Config(path)
        with open(path, "r") as f:
            for line in f:
                if len(line) > 0 and line[0] in Config._EXCLUDE:
                    continue
                valid = False
                for opt_name in Config._DEFAULT_OPTIONS.keys():
                    if line.startswith(opt_name):
                        valid = True
                        if opt_name == "TIKTOK_DIM":
                            config._insert_option(opt_name, tuple(line.split("=")[1].strip()))
                        else:
                            config._insert_option(opt_name, Config._parse_basic_option(line))
                                                  
                if not valid:
                    eprint("Error reading config file, Please check your config file!")

        Config._instance = config
        return config

    @staticmethod
    def _parse_basic_option(line: str):
        return line.split("=")[1].strip().replace('"', '')

    def get_option_by_name(self, opt_name: str):
        return self._options.get(opt_name)
    
    def _insert_option(self, opt_name: str, value):
        self._options[opt_name] = value

    @property
    def cookies_dir(self):
        """Path where selenium cookies are stored"""
        return self.get_option_by_name("COOKIES_DIR")

    @property
    def videos_dir(self):
        """Directory where videos are stored"""
        return self.get_option_by_name("VIDEOS_DIR")
    
    @property
    def post_processing_video_path(self):
        """Directory where video are saved after processing"""
        return self.get_option_by_name("POST_PROCESSING_VIDEO_PATH")

    @property
    def imagemagick_font(self):
        """Font used for video overlays by ImageMagick lib"""
        return self.get_option_by_name("IMAGEMAGICK_FONT")
    
    @property
    def imagemagick_font_size(self):
        """Font size used for video overlays by ImageMagick lib"""
        return self.get_option_by_name("IMAGEMAGICK_FONT_SIZE")
    
    @property
    def imagemagick_text_foreground_color(self):
        """Text foreground colour used for video overlays by ImageMagick lib"""
        return self.get_option_by_name("IMAGEMAGICK_TEXT_FOREGROUND_COLOR")

    @property
    def imagemagick_text_background_color(self):
        """Text background colour used for video overlays by ImageMagick lib"""
        return self.get_option_by_name("IMAGEMAGICK_TEXT_BACKGROUND_COLOR")
    
    @property
    def tiktok_video_size(self) -> tuple:
        """ Get tiktok dimension """
        return self.get_option_by_name("TIKTOK_VIDEO_SIZE")
    
    @property
    def tmp_youtube_video_dir(self):
        """Directory where YT videos are stored temporarily"""
        return self.get_option_by_name("TMP_YOUTUBE_VIDEO_DIR")
    
    @property
    def lang_preference(self):
        """Language preference"""
        return self.get_option_by_name("LANG")

    @property
    def tiktok_base_url(self):
        """Tiktok base url"""
        return self.get_option_by_name("TIKTOK_BASE_URL")

    @property
    def imagemagick_binary_path(self):
        """ImageMagick Binary path """
        return self.get_option_by_name("IMAGEMAGICK_BINARY")
-----------------------------
cookies.py

from .Config import Config
from .basics import eprint

import pickle
import os


def load_cookies_from_file(filename: str, cookies_path=None):
    if not cookies_path:
        cookie_path = os.path.join(os.getcwd(), Config.get().cookies_dir, filename + ".cookie")
    else:
        cookie_path = os.path.join(cookies_path, filename + ".cookie")
    if not os.path.exists(cookie_path):
        # eprint(f"Warning: Could not find cookie file at path: {cookie_path} (ignoring)")
        print("User not found on system.")
        return []
    
    cookie_data = pickle.load(open(cookie_path, "rb"))
    cookies = []
    for cookie in cookie_data:
        # still necessary?
        if 'sameSite' in cookie:
            if cookie['sameSite'] == 'None':
                cookie['sameSite'] = 'Strict'
        cookies.append(cookie)
    return cookies


def save_cookies_to_file(cookies, filename: str, cookies_path=None):
    if not cookies_path:
        cookie_path = os.path.join(os.getcwd(), Config.get().cookies_dir, filename + ".cookie")
    else:
        cookie_path = os.path.join(cookies_path, filename + ".cookie")
    print("Saving cookies to file: ", cookie_path)
    with open(cookie_path, "wb") as f:
        pickle.dump(cookies, f)
        f.close()


def delete_cookies_file(filename: str, cookies_path=None):
    if not cookies_path:
        cookie_path = os.path.join(os.getcwd(), Config.get().cookies_dir, filename + ".cookie")
    else:
        cookie_path = os.path.join(cookies_path, filename + ".cookie")
    if os.path.exists(cookie_path):
        os.remove(cookie_path)
        print("Deleted cookies file: ", cookie_path)
    else:
        print("No cookies file to delete: ", cookie_path)


def delete_all_cookies_files(cookies_path=None):
    if not cookies_path:
        cookie_dir = os.path.join(os.getcwd(), Config.get().cookies_dir)
    else:
        cookie_dir = cookies_path
    for filename in os.listdir(cookie_dir):
        if filename.endswith(".cookie"):
            os.remove(os.path.join(cookie_dir, filename))
            print("Deleted cookies file: ", filename)
    print("Deleted all cookies files.")


def update_dc_location(filename:str, new_dc_location: str):
    """As datacenter location can change per load, we need to update based on response set cookies headers, in the case of dc change, we need to update settings"""
    raise NotImplementedError("This function is not implemented yet.")
-----------------------------
tiktok.py

import time, requests, datetime, hashlib, hmac, random, zlib, json, datetime
import requests, zlib, json, time, subprocess, string, secrets, os, sys
from fake_useragent import FakeUserAgentError, UserAgent
from requests_auth_aws_sigv4 import AWSSigV4
from tiktok_uploader.cookies import load_cookies_from_file
from tiktok_uploader.Browser import Browser
from tiktok_uploader.bot_utils import *
from tiktok_uploader import Config, Video, eprint
from dotenv import load_dotenv


# Load environment variables
load_dotenv()

# Constants
_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36'


def login(login_name: str):
	# Check if login name is already save in file.
	cookies = load_cookies_from_file(f"tiktok_session-{login_name}")
	session_cookie = next((c for c in cookies if c["name"] == 'sessionid'), None)
	session_from_file = session_cookie is not None

	if session_from_file:
		print("Unnecessary login: session already saved!")
		return session_cookie["value"]

	browser = Browser.get()
	response = browser.driver.get(os.getenv("TIKTOK_LOGIN_URL"))

	session_cookies = []
	while not session_cookies:
		for cookie in browser.driver.get_cookies():
			if cookie["name"] in ["sessionid", "tt-target-idc"]:
				if cookie["name"] == "sessionid":
					cookie_name = cookie
				session_cookies.append(cookie)

	# print("Session cookie found: ", session_cookie["value"])
	print("Account successfully saved.")
	browser.save_cookies(f"tiktok_session-{login_name}", session_cookies)
	browser.driver.quit()

	return cookie_name.get('value', '') if cookie_name else ''


def upload_video(session_user, video, title, schedule_time=0, allow_comment=1, allow_duet=0, allow_stitch=0, visibility_type=0, brand_organic_type=0, branded_content_type=0, ai_label=0, proxy=None):
	try:
		user_agent = UserAgent().random
	except FakeUserAgentError as e:
		user_agent = _UA
		print("[-] Could not get random user agent, using default")

	cookies = load_cookies_from_file(f"tiktok_session-{session_user}")
	session_id = next((c["value"] for c in cookies if c["name"] == 'sessionid'), None)
	dc_id = next((c["value"] for c in cookies if c["name"] == 'tt-target-idc'), None)
	
	if not session_id:
		eprint("No cookie with Tiktok session id found: use login to save session id")
		sys.exit(1)
	if not dc_id:
		print("[WARNING]: Please login, tiktok datacenter id must be allocated, or may fail")
		dc_id = "useast2a"
	print("User successfully logged in.")
	print(f"Tiktok Datacenter Assigned: {dc_id}")
	
	print("Uploading video...")
	# Parameter validation,
	if schedule_time and (schedule_time > 864000 or schedule_time < 900):
		print("[-] Cannot schedule video in more than 10 days or less than 20 minutes")
		return False
	if len(title) > 2200:
		print("[-] The title has to be less than 2200 characters")
		return False
	if schedule_time != 0 and visibility_type == 1:
		print("[-] Private videos cannot be uploaded with schedule")
		return False

	# Check video length - 1 minute max, takes too long to run this.


	# Creating Session
	session = requests.Session()
	session.cookies.set("sessionid", session_id, domain=".tiktok.com")
	session.cookies.set("tt-target-idc", dc_id, domain=".tiktok.com")
	session.verify = True

	headers = {
		'User-Agent': user_agent,
		'Accept': 'application/json, text/plain, */*',
	}
	session.headers.update(headers)


	# Setting proxy if provided.
	if proxy:
		session.proxies = {
			"http": proxy,
			"https": proxy
		}

	creation_id = generate_random_string(21, True)
	project_url = f"https://www.tiktok.com/api/v1/web/project/create/?creation_id={creation_id}&type=1&aid=1988"
	r = session.post(project_url)

	if not assert_success(project_url, r):
		return False

	# get project_id
	project_id = r.json()["project"]["project_id"]
	video_id, session_key, upload_id, crcs, upload_host, store_uri, video_auth, aws_auth = upload_to_tiktok(video, session)

	url = f"https://{upload_host}/{store_uri}?uploadID={upload_id}&phase=finish&uploadmode=part"
	headers = {
		"Authorization": video_auth,
		"Content-Type": "text/plain;charset=UTF-8",
	}
	data = ",".join([f"{i + 1}:{crcs[i]}" for i in range(len(crcs))])

	if proxy:
		r = requests.post(url, headers=headers, data=data, proxies=session.proxies)
		if not assert_success(url, r):
			return False
	else:
		r = requests.post(url, headers=headers, data=data)
		if not assert_success(url, r):
			return False

	url = f"https://www.tiktok.com/top/v1?Action=CommitUploadInner&Version=2020-11-19&SpaceName=tiktok"
	data = '{"SessionKey":"' + session_key + '","Functions":[{"name":"GetMeta"}]}'

	r = session.post(url, auth=aws_auth, data=data)
	if not assert_success(url, r):
		return False

	# publish video
	url = "https://www.tiktok.com"
	headers = {
		"user-agent": user_agent
	}

	r = session.head(url, headers=headers)
	if not assert_success(url, r):
		return False

	headers = {
		"content-type": "application/json",
		"user-agent": user_agent
	}
	brand = ""

	if brand and brand[-1] == ",":
		brand = brand[:-1]
	markup_text, text_extra = convert_tags(title, session)
	data = {
		"upload_param": {
			"video_param": {
				"text": title,
				"text_extra": text_extra,
				"markup_text": markup_text,
				"poster_delay": 0,
			},
			"visibility_type": visibility_type,
			"allow_comment": allow_comment,
			"allow_duet": allow_duet,
			"allow_stitch": allow_stitch,
			"sound_exemption": 0,
			"geofencing_regions": [],
			"creation_id": creation_id,
			"is_uploaded_in_batch": False,
			"is_enable_playlist": False,
			"is_added_to_playlist": False,
			"tcm_params": '{"commerce_toggle_info":' + brand + "}",
			"aigc_info": {
				"aigc_label_type": ai_label
			}
		},
		"project_id": project_id,
		"draft": "",
		"single_upload_param": [],
		"video_id": video_id,
		"creation_id": creation_id,
	}
	if schedule_time:
		data["upload_param"]["schedule_time"] = schedule_time + int(time.time())
	uploaded = False
	while True:
		mstoken = session.cookies.get("msToken")
		# xbogus = subprocess_jsvmp(os.path.join(os.getcwd(), "tiktok_uploader", "./x-bogus.js"), user_agent, f"app_name=tiktok_web&channel=tiktok_web&device_platform=web&aid=1988&msToken={mstoken}")
		signatures = subprocess_jsvmp(os.path.join(os.getcwd(), "tiktok_uploader", "tiktok-signature", "browser.js"), user_agent, f"https://www.tiktok.com/api/v1/web/project/post/?app_name=tiktok_web&channel=tiktok_web&device_platform=web&aid=1988&msToken={mstoken}")
		tt_output = json.loads(signatures)["data"]
		project_post_dict = {
			"app_name": "tiktok_web",
			"channel": "tiktok_web",
			"device_platform": "web",
			"aid": 1988,
			"msToken": mstoken,
			"X-Bogus": tt_output["x-bogus"],
			"_signature": tt_output["signature"],
			# "X-TT-Params": tt_output["x-tt-params"],  # not needed rn.
		}
		url = f"https://www.tiktok.com/api/v1/web/project/post/"
		r = session.request("POST", url, params=project_post_dict, data=json.dumps(data), headers=headers)
		try:
			if r.json()["status_msg"] == "You are posting too fast. Take a rest.":
				print("[-] You are posting too fast, try later again")
				return False
			print(r.json())
			uploaded = True
			break
		except Exception as e:
			print("[-] Waiting for TikTok to process video...")
			time.sleep(1.5)  # wait 1.5 seconds before asking again.
	if not uploaded:
		print("[-] Could not upload video")
		return False

	# Check if video uploaded successfully
	url = f"https://www.tiktok.com/api/v1/web/project/list/?aid=1988"

	r = session.get(url)
	if not assert_success(url, r):
		return False
	# print(r.json()["infos"])
	for j in r.json()["infos"]:
		if j["creationID"] == creation_id:
			if j["tasks"][0]["status_msg"] == "Y project task init" or j["tasks"][0]["status_msg"] == "Success":
				print("[+] Video uploaded successfully.")
				return True
			print(f"[-] Video could not be uploaded: {j['tasks'][0]['status_msg']}")
			return False


def upload_to_tiktok(video_file, session):
	url = "https://www.tiktok.com/api/v1/video/upload/auth/?aid=1988"
	r = session.get(url)
	if not assert_success(url, r):
		return False

	aws_auth = AWSSigV4(
		"vod",
		region="ap-singapore-1",
		aws_access_key_id=r.json()["video_token_v5"]["access_key_id"],
		aws_secret_access_key=r.json()["video_token_v5"]["secret_acess_key"],
		aws_session_token=r.json()["video_token_v5"]["session_token"],
	)
	with open(os.path.join(os.getcwd(), Config.get().videos_dir, video_file), "rb") as f:
		video_content = f.read()
	file_size = len(video_content)
	url = f"https://www.tiktok.com/top/v1?Action=ApplyUploadInner&Version=2020-11-19&SpaceName=tiktok&FileType=video&IsInner=1&FileSize={file_size}&s=g158iqx8434"

	r = session.get(url, auth=aws_auth)
	if not assert_success(url, r):
		return False

	# upload chunks
	upload_node = r.json()["Result"]["InnerUploadAddress"]["UploadNodes"][0]
	video_id = upload_node["Vid"]
	store_uri = upload_node["StoreInfos"][0]["StoreUri"]
	video_auth = upload_node["StoreInfos"][0]["Auth"]
	upload_host = upload_node["UploadHost"]
	session_key = upload_node["SessionKey"]
	chunk_size = 5242880
	chunks = []
	i = 0
	while i < file_size:
		chunks.append(video_content[i: i + chunk_size])
		i += chunk_size
	crcs = []
	upload_id = str(uuid.uuid4())
	for i in range(len(chunks)):
		chunk = chunks[i]
		crc = crc32(chunk)
		crcs.append(crc)
		url = f"https://{upload_host}/{store_uri}?partNumber={i + 1}&uploadID={upload_id}&phase=transfer"
		headers = {
			"Authorization": video_auth,
			"Content-Type": "application/octet-stream",
			"Content-Disposition": 'attachment; filename="undefined"',
			"Content-Crc32": crc,
		}

		r = session.post(url, headers=headers, data=chunk)

	return video_id, session_key, upload_id, crcs, upload_host, store_uri, video_auth, aws_auth




if __name__ == "__main__":
	# Testing login function
	# login("test")
	ms_token = ""
	# path = os.path.join(os.getcwd(), "./x-bogus.js")
	# print(path)
	# print(user_agent)
	base_url = "https://www.tiktok.com/api/v1/web/project/post/"
	url = f"?app_name=tiktok_web&channel=tiktok_web&device_platform=web&aid=1988&msToken={ms_token}"
	# xbogus = subprocess_jsvmp(path, user_agent, url)
	# print(xbogus)

	path = os.path.join(os.getcwd(), "tiktok-signature", "browser.js")
	proc = subprocess.Popen(['node', path , base_url+url, "agent123"], stdout=subprocess.PIPE)
	output = proc.stdout.read().decode('utf-8')
	json_output = json.loads(output)["data"]
	print(json_output)
	print(f""
	      f"X-Bogus: {json_output['x-bogus']}\n"
	      f"Signature: {json_output['signature']}\n"
	      f"Signed URL: {json_output['signed_url']}\n"
	      f"X TT Params: {json_output['x-tt-params']}\n"
		  f"User Agent: {json_output['navigator']['user_agent']}\n"
	      f"")

--------------------------------
Video.py

from .Config import Config

from moviepy.editor import *
from moviepy.editor import VideoFileClip, AudioFileClip
from pytube import YouTube
import time, os

class Video:
    def __init__(self, source_ref, video_text):
        self.config = Config.get()
        self.source_ref = source_ref
        self.video_text = video_text

        self.source_ref = self.downloadIfYoutubeURL()
        # Wait until self.source_ref is found in the file system.
        while not os.path.isfile(self.source_ref):
            time.sleep(1)

        self.clip = VideoFileClip(self.source_ref)


    def crop(self, start_time, end_time, saveFile=False):
        if end_time > self.clip.duration:
            end_time = self.clip.duration
        save_path = os.path.join(os.getcwd(), self.config.videos_dir, "processed") + ".mp4"
        self.clip = self.clip.subclip(t_start=start_time, t_end=end_time)
        if saveFile:
            self.clip.write_videofile(save_path)
        return self.clip


    def createVideo(self):
        self.clip = self.clip.resize(width=1080)
        base_clip = ColorClip(size=(1080, 1920), color=[10, 10, 10], duration=self.clip.duration)
        bottom_meme_pos = 960 + (((1080 / self.clip.size[0]) * (self.clip.size[1])) / 2) + -20
        if self.video_text:
            try:
                meme_overlay = TextClip(txt=self.video_text, bg_color=self.config.imagemagick_text_background_color, color=self.config.imagemagick_text_foreground_color, size=(900, None), kerning=-1,
                            method="caption", font=self.config.imagemagick_font, fontsize=self.config.imagemagick_font_size, align="center")
            except OSError as e:
                print("Please make sure that you have ImageMagick is not installed on your computer, or (for Windows users) that you didn't specify the path to the ImageMagick binary in file conf.py, or that the path you specified is incorrect")
                print("https://imagemagick.org/script/download.php#windows")
                print(e)
                exit()
            meme_overlay = meme_overlay.set_duration(self.clip.duration)
            self.clip = CompositeVideoClip([base_clip, self.clip.set_position(("center", "center")),
                                            meme_overlay.set_position(("center", bottom_meme_pos))])
            # Continue normal flow.

        dir = os.path.join(self.config.post_processing_video_path, "post-processed")+".mp4"
        self.clip.write_videofile(dir, fps=24)
        return dir, self.clip


    def is_valid_file_format(self):
        if not self.source_ref.endswith('.mp4') and not self.source_ref.endswith('.webm'):
            exit(f"File: {self.source_ref} has wrong file extension. Must be .mp4 or .webm.")

    def get_youtube_video(self, max_res=True):
        url = self.source_ref
        streams = YouTube(url).streams.filter(progressive=True)
        valid_streams = sorted(streams, reverse=True, key=lambda x: x.resolution is not None)
        filtered_streams = sorted(valid_streams, reverse=True, key=lambda x: int(x.resolution.split("p")[0]))
        if filtered_streams:
            selected_stream = filtered_streams[0]
            print("Starting Download for Video...")
            selected_stream.download(output_path=os.path.join(os.getcwd(), Config.get().videos_dir), filename="pre-processed.mp4")
            filename = os.path.join(os.getcwd(), Config.get().videos_dir, "pre-processed"+".mp4")
            return filename


        video = YouTube(url).streams.filter(file_extension="mp4", adaptive=True).first()
        audio = YouTube(url).streams.filter(file_extension="webm", only_audio=True, adaptive=True).first()
        if video and audio:
            random_filename = str(int(time.time()))  # extension is added automatically.
            video_path = os.path.join(os.getcwd(), Config.get().videos_dir, "pre-processed.mp4")
            resolution = int(video.resolution[:-1])
            # print(resolution)
            if resolution >= 360:
                downloaded_v_path = video.download(output_path=os.path.join(os.getcwd(), self.config.videos_dir), filename=random_filename)
                print("Downloaded Video File @ " + video.resolution)
                downloaded_a_path = audio.download(output_path=os.path.join(os.getcwd(), self.config.videos_dir), filename="a" + random_filename)
                print("Downloaded Audio File")
                file_check_iter = 0
                while not os.path.exists(downloaded_a_path) and os.path.exists(downloaded_v_path):
                    time.sleep(2**file_check_iter)
                    file_check_iter = +1
                    if file_check_iter > 3:
                        print("Error saving these files to directory, please try again")
                        return
                    print("Waiting for files to appear.")

                composite_video = VideoFileClip(downloaded_v_path).set_audio(AudioFileClip(downloaded_a_path))
                composite_video.write_videofile(video_path)
                # Deleting raw video and audio files.
                # os.remove(downloaded_a_path)
                # os.remove(downloaded_v_path)
                return video_path
            else:
                print("All videos have are too low of quality.")
                return
        print("No videos available with both audio and video available...")
        return False

    _YT_DOMAINS = [
        "http://youtu.be/", "https://youtu.be/", "http://youtube.com/", "https://youtube.com/",
        "https://m.youtube.com/", "http://www.youtube.com/", "https://www.youtube.com/"
    ]
    
    def downloadIfYoutubeURL(self):
            if any(ext in self.source_ref for ext in Video._YT_DOMAINS):
                print("Detected Youtube Video...")
                video_dir = self.get_youtube_video()
                return video_dir
            return self.source_ref
---------------------------------
.deepsource.toml

version = 1

[[analyzers]]
name = "python"
enabled = true

  [analyzers.meta]
  runtime_version = "3.x.x"
-----------------------
.gitignore

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

# C extensions
*.so

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

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

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

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

# Translations
*.mo
*.pot

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

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
.python-version

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

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

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

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

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

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

# Pyre type checker
.pyre/

# Personal Development Files
CookiesDir/
CookiesDir/*
VideosDirPath/
*.cookie
output/
*.mp4
youtube_downloader.py


node_modules/

tests/*
.idea/*

tiktok_uploader\tiktok-signature\command.txt
------------------------------
changes.txt

# Updated Project Changes 31/08/2021


# New Reliable Chromedriver Used
# A more reliable chromedriver executable is used from repository: https://github.com/ultrafunkamsterdam/undetected-chromedriver
# Hopefully this will lower error rates from tiktok detecting this bot using various javascript libraries.

# Useragent Randomisation
# Instead of using "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36",
# We will use the repository: https://github.com/hellysmile/fake-useragent, for a random useragent every login.

# Abstraction in Bot.py.
# Many click javascript functions were repetitive and made for very confusing code, I also code less prone to errors due
# to Javascript by handling Javascript Errors from selenium.

# Fixes in Browser and Bot.py
# Some functions were not needed and confused code in both these files, and so has been changed.

# Cookies Issues Fixed
# Google has a policy not allowing "sameSite" property to be allocated to None and so yields an error, this has been fixed.


# Notes
# All tiktok logins works as normal!

# I'm currently working on scheduling tiktok videos, so videos can be scheduled for any date in the future.

# There are some versions conflicts in requirements.txt, I will fix asap.

# Please update undetected chrome driver package using pip git+git:// instead of the pypi package.
----------------------------
cli.py

import argparse
from tiktok_uploader import tiktok, Video
from tiktok_uploader.basics import eprint
from tiktok_uploader.Config import Config
import sys, os

if __name__ == "__main__":
    _ = Config.load("./config.txt")
    # print(Config.get().cookies_dir)
    parser = argparse.ArgumentParser(description="TikTokAutoUpload CLI, scheduled and immediate uploads")
    subparsers = parser.add_subparsers(dest="subcommand")

    # Login subcommand.
    login_parser = subparsers.add_parser("login", help="Login into TikTok to extract the session id (stored locally)")
    login_parser.add_argument("-n", "--name", help="Name to save cookie as", required=True)

    # Upload subcommand.
    upload_parser = subparsers.add_parser("upload", help="Upload video on TikTok")
    upload_parser.add_argument("-u", "--users", help="Enter cookie name from login", required=True)
    upload_parser.add_argument("-v", "--video", help="Path to video file")
    upload_parser.add_argument("-yt", "--youtube", help="Enter Youtube URL")
    upload_parser.add_argument("-t", "--title", help="Title of the video", required=True)
    upload_parser.add_argument("-sc", "--schedule", type=int, default=0, help="Schedule time in seconds")
    upload_parser.add_argument("-ct", "--comment", type=int, default=1, choices=[0, 1])
    upload_parser.add_argument("-d", "--duet", type=int, default=0, choices=[0, 1])
    upload_parser.add_argument("-st", "--stitch", type=int, default=0, choices=[0, 1])
    upload_parser.add_argument("-vi", "--visibility", type=int, default=0, help="Visibility type: 0 for public, 1 for private")
    upload_parser.add_argument("-bo", "--brandorganic", type=int, default=0)
    upload_parser.add_argument("-bc", "--brandcontent", type=int, default=0)
    upload_parser.add_argument("-ai", "--ailabel", type=int, default=0)
    upload_parser.add_argument("-p", "--proxy", default="")

    # Show cookies
    show_parser = subparsers.add_parser("show", help="Show users and videos available for system.")
    show_parser.add_argument("-u", "--users", action='store_true', help="Shows all available cookie names")
    show_parser.add_argument("-v", "--videos",  action='store_true', help="Shows all available videos")

    # Parse the command-line arguments
    args = parser.parse_args()

    if args.subcommand == "login":
        if not hasattr(args, 'name') or args.name is None:
            parser.error("The 'name' argument is required for the 'login' subcommand.")
        # Name of file to save the session id.
        login_name = args.name
        # Name of file to save the session id.
        tiktok.login(login_name)

    elif args.subcommand == "upload":
        # Obtain session id from the cookie name.
        if not hasattr(args, 'users') or args.users is None:
            parser.error("The 'cookie' argument is required for the 'upload' subcommand.")
        
        # Check if source exists,
        if args.video is None and args.youtube is None:
            eprint("No source provided. Use -v or -yt to provide video source.")
            sys.exit(1)
        if args.video and args.youtube:
            eprint("Both -v and -yt flags cannot be used together.")
            sys.exit(1)

        if args.youtube:
            video_obj = Video(args.youtube, args.title)
            video_obj.is_valid_file_format()
            video = video_obj.source_ref
            args.video = video
        else:
            if not os.path.exists(os.path.join(os.getcwd(), Config.get().videos_dir, args.video)) and args.video:
                print("[-] Video does not exist")
                print("Video Names Available: ")
                video_dir = os.path.join(os.getcwd(), Config.get().videos_dir)
                for name in os.listdir(video_dir):
                    print(f'[-] {name}')
                sys.exit(1)

        tiktok.upload_video(args.users, args.video,  args.title, args.schedule, args.comment, args.duet, args.stitch, args.visibility, args.brandorganic, args.brandcontent, args.ailabel, args.proxy)

    elif args.subcommand == "show":
        # if flag is c then show cookie names
        if args.users:
            print("User Names logged in: ")
            cookie_dir = os.path.join(os.getcwd(), Config.get().cookies_dir)
            for name in os.listdir(cookie_dir):
                if name.startswith("tiktok_session-"):
                    print(f'[-] {name.split("tiktok_session-")[1]}')

        # if flag is v then show video names
        if args.videos:
            print("Video Names: ")
            video_dir = os.path.join(os.getcwd(), Config.get().videos_dir)
            for name in os.listdir(video_dir):
                print(f'[-] {name}')
        elif not args.users and not args.videos:
            print("No flag provided. Use -c (show all cookies) or -v (show all videos).")

    else:
        eprint("Invalid subcommand. Use 'login' or 'upload' or 'show'.")

-----------------------------------
Common Problems Help Readme.md

# Common Problems Help Readme

-----

## Cookies Problems:



### Using current CLI commands to store cookies,

Currently there is one way to obtain these cookies, 

`python cli.py login -n username`

This will open up a chrome window, which will prompt you to login to Tiktok, please make sure you have the latest version of Chrome installed on your system.

When logged in this cookie will be saved on your system, to view all cookies currently on system, use command:

`python cli.py show -c `

This will ensure that you can upload videos using that exact username, through command:

`python cli.py upload --user username -v "video.mp4" -t "My video title" `

### Manually add cookies using browser

Alternatively, you may want to obtain cookies manually, to do so, 

By using tiktok cookies function `save_cookies_to_file`

for the object to be saved, input the following:

```python
cookies = [{'domain': '.tiktok.com', 'expiry': EXPIRY_FROM_BROWSER, 'httpOnly': True, 'name': 'sessionid', 'path': '/', 'sameSite': 'Lax', 'secure': True, 'value': 'YOUR_SESSION_KEY_FROM_BROWSER'}]

save_cookies_to_file(cookies, "username.cookie")  # You must have the .cookie extension.
```

-------------------------------
config.txt


COOKIES_DIR= "./CookiesDir"
VIDEOS_DIR= "./VideosDirPath"
POST_PROCESSING_VIDEO_PATH= "./VideosDirPath"
IMAGEMAGICK_FONT= "Arial"
IMAGEMAGICK_FONT_SIZE= 80
IMAGEMAGICK_TEXT_FOREGROUND_COLOR= "white"
IMAGEMAGICK_TEXT_BACKGROUND_COLOR= "black"
TIKTOK_VIDEO_SIZE= (1920, 1080)
TMP_YOUTUBE_VIDEO_DIR= ""
LANG= "en"
TIKTOK_BASE_URL= "https=//www.tiktok.com/upload?lang="
IMAGEMAGICK_BINARY= ""
----------------------------
README.MD

# TiktokAutoUploader v2.0

Fastest Tiktok AutoUploader using Requests, not ~~Selenium~~

Automatically Uploads to Tiktok with 1 command and within 3 seconds.

[![LinkedIn](https://img.shields.io/badge/LinkedIn-0077B5?style=for-the-badge&logo=linkedin&logoColor=white&style=flat-square)]([<PRESIDIO_ANONYMIZED_URL>(<PRESIDIO_ANONYMIZED_URL>) )
[![HitCount](https://hits.dwyl.com/makiisthenes/TiktokAutoUploader.svg?style=flat)](http://hits.dwyl.com/makiisthenes/https://githubcom/makiisthenes/TiktokAutoUploader)
![Forks](https://img.shields.io/github/forks/makiisthenes/TiktokAutoUploader)
![Stars](https://img.shields.io/github/stars/makiisthenes/TiktokAutoUploader)

<p align="center">
<image src="https://user-images.githubusercontent.com/52138450/111885490-04ab6680-89c0-11eb-955a-f833577b4406.png" width="35%">
</p>
<p align="center">
  <img alt="Forks" src="https://img.shields.io/github/forks/makiisthenes/TiktokAutoUploader" />
  <img alt="Stars" src="https://img.shields.io/github/stars/makiisthenes/TiktokAutoUploader" />
  <img alt="Watchers" src="https://img.shields.io/github/watchers/makiisthenes/TiktokAutoUploader" />
</p>

<p align="center">The <strong>Fastest</strong> Known <strong>TikTok Auto Video Uploader</strong> with requests not Selenium!</p>

--------------------------------------

# Quickstart

This guide covers how to get setup and running your bot ASAP, making basic usage of the library.

Want to manage multiple accounts, schedule more than 10 days ahead, and obtain videos from multiple sources automatically, use our service?

- ✔️ Uses Requests not Selenium (Super Fast)

- ✔️ Will not break when site layout changes. (Robust)

- ✔️ Handle multiple accounts on local machine (Multi-uploads)

- ✔️ Schedule videos up to 10 days in the future. (Autonomy)

- ✔️ Upload your own videos or use youtube short links. (Sourcing)

--------------------------------------
# Prerequistes

You must have Node installed on your computer, in order to run this, 
Please follow instructions in the provided URL, 

`https://nodejs.org/en/download`

Please make sure `node` is in your environment path before running, as it is required in the upload stage. 


--------------------------------------
### Installation

Clone the repository.

```bash
git clone https://github.com/makiisthenes/TiktokAutoUploader.git
```

Install requirements for package.

```bash
pip install -r requirements.txt
```
Install node packages.
```bash
cd tiktok_uploader/tiktok-signature/
npm i
```

------------
### Demo
Video showcases main usage of the app, uploading a video to TikTok.

<p align="center">
  <video src="https://github.com/makiisthenes/TiktokAutoUploader/assets/52138450/3dc36fd4-b9f4-4059-bcb4-c2ddca2a285d" controls poster="poster.jpg" width="320" height="240">
  </video>
</p>



------------


### Using program in CLI:

-----------------------------------

### Login to Account 🔒:

System handles multiple user accounts logging in, and will save this to system. This will prompt you to login into your tiktok account and store these cookies locally.

> ```bash
> # Login
> 
> python cli.py login -n my_saved_username
> ```

### Upload Videos 🖼️:

Users can select user, and upload a video from path or directly from a youtube shorts link.

```bash
# Upload from videos path
python cli.py upload --user my_saved_username -v "video.mp4" -t "My video title" 
```

```bash
# Upload from youtube link
python cli.py upload --user my_saved_username -yt "https://www.youtube.com/shorts/#####" -t "My video title" 
```

--------------------------------

### Show Current Users and Videos ⚙️:

All local videos must be saved under folder `VideosDirPath` if this doesn't exist please create one.

```bash
# Show all current videos found on system.
python cli.py show -v 
```

All cookies must be saved under folder `CookiesDir`, if this doesn't exist please create one.

```bash
# Show all current cookies found on system.
python cli.py show -c 
```

-----

### Help Command ℹ️:

If you are unsure with command, use the flag `-h`

```bash
# Show all current videos found on system.
python cli.py -h
python cli.py show -h
python cli.py login -h
python cli.py upload -h
```

 ----

## Professional Software💼

Fill waiting list form: https://forms.gle/M4KpdfruqCukQvj99

If you are looking for something more, which can get you faster to your goal, I offer software which can:

- ⭐ Clean and Modern UI

- ⭐ Proxy Support

- ⭐ Handling more than 1000 accounts!

- ⭐ Upload identical vidoes to multiple accounts automatically

- ⭐ Schedule videos for multiple accounts, 20 days to 2 years in advance. 

- 🌌 Automatically source videos from YouTube, X, Reddit, TikTok.

- 🌌 Setup uploading pipelines, from source to uploading schedule!

- 🌌 Metrics for viewing current performance of these different accounts.

- 🌌 Personalised support from me for any issues you may face for up to 3 months.

Available for purchase, if interested please email me at `<PRESIDIO_ANONYMIZED_EMAIL_ADDRESS>` with subject line `Tiktok Bot Software` or else I might miss the email.

------

### Support this project ❤️

If you like the work provided, please consider supporting me through the available links for [Patreon ](https://patreon.com/makiisthenes)and [Ko-Fi](https://ko-fi.com/makiperes). 

Else if you have any requests or would like to contribute send a PR.

Alternative consider starring the project, or giving me a follow ;)

----

### Bugs and Issues and Future Work 🛠️

If you find any bugs or issues, please add to the issues tab, please do not email me relating to this, I will see on issues.

Will work to make this more user friendly including making a PyPI package.

------

### Old Branch 📕

If you would like to continue to use the library based uploader, please forward to old branch namely `old`

This still relies on Selenium which is slow and unreliable.

----

### Notes and Terms⌛

I am not responsible for any effects to your account, usage of such tools may ban your account. Please use at your own risk. 
----------------------------
requirements.txt

appdirs==1.4.4
attrs==23.2.0
beautifulsoup4==4.12.3
bs4==0.0.2
certifi==2024.2.2
cffi==1.16.0
charset-normalizer==3.3.2
colorama==0.4.6
cssselect==1.2.0
decorator==4.4.2
exceptiongroup==1.2.0
fake-useragent==1.4.0
h11==0.14.0
idna==3.6
imageio==2.33.1
imageio-ffmpeg==0.4.9
importlib-metadata==7.0.1
lxml==5.1.0
moviepy==1.0.3
nose2==0.14.1
numpy==1.26.4
outcome==1.3.0.post0
parse==1.20.1
pillow==10.2.0
proglog==0.1.10
pycparser==2.21
pyee==8.2.2
pyppeteer==1.0.2
pyquery==2.0.0
PySocks==1.7.1
python-dotenv==1.0.1
pytube @ git+https://github.com/pytube/pytube@a32fff39058a6f7e5e59ecd06a7467b71197ce35
requests==2.31.0
requests-auth-aws-sigv4==0.7
requests-html==0.10.0
selenium==4.17.2
sniffio==1.3.0
sortedcontainers==2.4.0
soupsieve==2.5
tqdm==4.66.2
trio==0.24.0
trio-websocket==0.11.1
typing_extensions==4.9.0
undetected-chromedriver @ git+https://github.com/ultrafunkamsterdam/undetected-chromedriver.git
urllib3
websockets
wsproto==1.2.0
zipp==3.17.0
-------------------------------------
Setup.py

from setuptools import setup
import os

setup(
    name='TikTokAutomation',
    version='1.0.0',
    packages=[''],
    url='',
    license='',
    author='Michael',
    <PRESIDIO_ANONYMIZED_EMAIL_ADDRESS>',
    description='Tiktok Automatic Uploader'
)
---------------------------
instagram_creds.conf

[instagram]
username = <PRESIDIO_ANONYMIZED_EMAIL_ADDRESS>
password = yourInstagramPassword
-----------------------------
youtubeupload.py

import os
import json
import argparse
import google.auth
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
import re

SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
script_dir = os.path.dirname(os.path.abspath(__file__))
TOKEN_FILE = os.path.join(script_dir, "YTtoken.json")   # Path where token will be saved

def get_authenticated_service():
    credentials = None


    # Load credentials from token.json if it exists
    if os.path.exists(TOKEN_FILE):
        with open(TOKEN_FILE, "r") as token_file:
            credentials = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)

    # If credentials don't exist or are invalid, generate them
    if not credentials or not credentials.valid:
        if credentials and credentials.expired and credentials.refresh_token:
            credentials.refresh(google.auth.transport.requests.Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(os.path.join(script_dir, "youtube_client_secret.json"), SCOPES)
            credentials = flow.run_local_server(port=0)

        # Save the credentials for future use
        with open(TOKEN_FILE, "w") as token_file:
            token_file.write(credentials.to_json())

    return build("youtube", "v3", credentials=credentials)

def upload_video(youtube, video_file, description):

    title, _ = os.path.splitext(os.path.basename(video_file))
    sanitized_title = re.sub(r'[^\x00-\x7F]+', '', title)
    print("Uploading: "+ sanitized_title)

    body = {
        "snippet": {
            "title": sanitized_title,  # Video title is the filename
            "description": sanitized_title + " #shorts #short",
            "tags": ["shorts"],  # Add tag for YouTube Shorts
            "categoryId": "22"  # Category ID for People & Blogs
        },
        "status": {
            "privacyStatus": "public",  # Can also be "private" or "unlisted"
             "madeForKids": False
        }
    }

    media = MediaFileUpload(video_file, chunksize=-1, resumable=True)
    request = youtube.videos().insert(
        part="snippet,status",
        body=body,
        media_body=media
    )

    response = None
    while response is None:
        status, response = request.next_chunk()
        if status:
            print(f"Uploading... {int(status.progress() * 100)}% complete.")
    print(f"Video uploaded successfully: https://youtu.be/{response['id']}")

def main():
    parser = argparse.ArgumentParser(description="Upload a YouTube Short")
    parser.add_argument("video_file", help="Path to the video file")
    parser.add_argument("description", help="Description of the video")
    args = parser.parse_args()

    youtube = get_authenticated_service()
    upload_video(youtube, args.video_file, args.description)

if __name__ == "__main__":
    main()
--------------------
utils
_inti_.py
.config.template.toml
ai_methods.py

import numpy as np
import torch
from transformers import AutoModel, AutoTokenizer


# Mean Pooling - Take attention mask into account for correct averaging
def mean_pooling(model_output, attention_mask):
    token_embeddings = model_output[0]  # First element of model_output contains all token embeddings
    input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
    return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(
        input_mask_expanded.sum(1), min=1e-9
    )


# This function sort the given threads based on their total similarity with the given keywords
def sort_by_similarity(thread_objects, keywords):
    # Initialize tokenizer + model.
    tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")
    model = AutoModel.from_pretrained("sentence-transformers/all-MiniLM-L6-v2")

    # Transform the generator to a list of Submission Objects, so we can sort later based on context similarity to
    # keywords
    thread_objects = list(thread_objects)

    threads_sentences = []
    for i, thread in enumerate(thread_objects):
        threads_sentences.append(" ".join([thread.title, thread.selftext]))

    # Threads inference
    encoded_threads = tokenizer(
        threads_sentences, padding=True, truncation=True, return_tensors="pt"
    )
    with torch.no_grad():
        threads_embeddings = model(**encoded_threads)
    threads_embeddings = mean_pooling(threads_embeddings, encoded_threads["attention_mask"])

    # Keywords inference
    encoded_keywords = tokenizer(keywords, padding=True, truncation=True, return_tensors="pt")
    with torch.no_grad():
        keywords_embeddings = model(**encoded_keywords)
    keywords_embeddings = mean_pooling(keywords_embeddings, encoded_keywords["attention_mask"])

    # Compare every keyword w/ every thread embedding
    threads_embeddings_tensor = torch.tensor(threads_embeddings)
    total_scores = torch.zeros(threads_embeddings_tensor.shape[0])
    cosine_similarity = torch.nn.CosineSimilarity()
    for keyword_embedding in keywords_embeddings:
        keyword_embedding = torch.tensor(keyword_embedding).repeat(
            threads_embeddings_tensor.shape[0], 1
        )
        similarity = cosine_similarity(keyword_embedding, threads_embeddings_tensor)
        total_scores += similarity

    similarity_scores, indices = torch.sort(total_scores, descending=True)

    threads_sentences = np.array(threads_sentences)[indices.numpy()]

    thread_objects = np.array(thread_objects)[indices.numpy()].tolist()

    # print('Similarity Thread Ranking')
    # for i, thread in enumerate(thread_objects):
    #    print(f'{i}) {threads_sentences[i]} score {similarity_scores[i]}')

    return thread_objects, similarity_scores
------------------
background_audios.json
background_videos.json
-------------------------------
cleanup.py

import os
import shutil
from os.path import exists


def _listdir(d):  # listdir with full path
    return [os.path.join(d, f) for f in os.listdir(d)]


def cleanup(reddit_id) -> int:
    """Deletes all temporary assets in assets/temp

    Returns:
        int: How many files were deleted
    """
    directory = f"../assets/temp/{reddit_id}/"
    if exists(directory):
        shutil.rmtree(directory)

        return 1
----------------------
console.py

import re

from rich.columns import Columns
from rich.console import Console
from rich.markdown import Markdown
from rich.padding import Padding
from rich.panel import Panel
from rich.text import Text

console = Console()


def print_markdown(text) -> None:
    """Prints a rich info message. Support Markdown syntax."""

    md = Padding(Markdown(text), 2)
    console.print(md)


def print_step(text) -> None:
    """Prints a rich info message."""

    panel = Panel(Text(text, justify="left"))
    console.print(panel)


def print_table(items) -> None:
    """Prints items in a table."""

    console.print(Columns([Panel(f"[yellow]{item}", expand=True) for item in items]))


def print_substep(text, style="") -> None:
    """Prints a rich colored info message without the panelling."""
    console.print(text, style=style)


def handle_input(
    message: str = "",
    check_type=False,
    match: str = "",
    err_message: str = "",
    nmin=None,
    nmax=None,
    oob_error="",
    extra_info="",
    options: list = None,
    default=NotImplemented,
    optional=False,
):
    if optional:
        console.print(message + "\n[green]This is an optional value. Do you want to skip it? (y/n)")
        if input().casefold().startswith("y"):
            return default if default is not NotImplemented else ""
    if default is not NotImplemented:
        console.print(
            "[green]"
            + message
            + '\n[blue bold]The default value is "'
            + str(default)
            + '"\nDo you want to use it?(y/n)'
        )
        if input().casefold().startswith("y"):
            return default
    if options is None:
        match = re.compile(match)
        console.print("[green bold]" + extra_info, no_wrap=True)
        while True:
            console.print(message, end="")
            user_input = input("").strip()
            if check_type is not False:
                try:
                    user_input = check_type(user_input)
                    if (nmin is not None and user_input < nmin) or (
                        nmax is not None and user_input > nmax
                    ):
                        # FAILSTATE Input out of bounds
                        console.print("[red]" + oob_error)
                        continue
                    break  # Successful type conversion and number in bounds
                except ValueError:
                    # Type conversion failed
                    console.print("[red]" + err_message)
                    continue
            elif match != "" and re.match(match, user_input) is None:
                console.print("[red]" + err_message + "\nAre you absolutely sure it's correct?(y/n)")
                if input().casefold().startswith("y"):
                    break
                continue
            else:
                # FAILSTATE Input STRING out of bounds
                if (nmin is not None and len(user_input) < nmin) or (
                    nmax is not None and len(user_input) > nmax
                ):
                    console.print("[red bold]" + oob_error)
                    continue
                break  # SUCCESS Input STRING in bounds
        return user_input
    console.print(extra_info, no_wrap=True)
    while True:
        console.print(message, end="")
        user_input = input("").strip()
        if check_type is not False:
            try:
                isinstance(eval(user_input), check_type)
                return check_type(user_input)
            except:
                console.print(
                    "[red bold]"
                    + err_message
                    + "\nValid options are: "
                    + ", ".join(map(str, options))
                    + "."
                )
                continue
        if user_input in options:
            return user_input
        console.print(
            "[red bold]" + err_message + "\nValid options are: " + ", ".join(map(str, options)) + "."
        )#
--------------------------
ffmpeg_install.py

import os
import subprocess
import zipfile

import requests


def ffmpeg_install_windows():
    try:
        ffmpeg_url = (
            "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.zip"
        )
        ffmpeg_zip_filename = "ffmpeg.zip"
        ffmpeg_extracted_folder = "ffmpeg"

        # Check if ffmpeg.zip already exists
        if os.path.exists(ffmpeg_zip_filename):
            os.remove(ffmpeg_zip_filename)

        # Download FFmpeg
        r = requests.get(ffmpeg_url)
        with open(ffmpeg_zip_filename, "wb") as f:
            f.write(r.content)

        # Check if the extracted folder already exists
        if os.path.exists(ffmpeg_extracted_folder):
            # Remove existing extracted folder and its contents
            for root, dirs, files in os.walk(ffmpeg_extracted_folder, topdown=False):
                for file in files:
                    os.remove(os.path.join(root, file))
                for dir in dirs:
                    os.rmdir(os.path.join(root, dir))
            os.rmdir(ffmpeg_extracted_folder)

        # Extract FFmpeg
        with zipfile.ZipFile(ffmpeg_zip_filename, "r") as zip_ref:
            zip_ref.extractall()
        os.remove("ffmpeg.zip")

        # Rename and move files
        os.rename(f"{ffmpeg_extracted_folder}-6.0-full_build", ffmpeg_extracted_folder)
        for file in os.listdir(os.path.join(ffmpeg_extracted_folder, "bin")):
            os.rename(
                os.path.join(ffmpeg_extracted_folder, "bin", file),
                os.path.join(".", file),
            )
        os.rmdir(os.path.join(ffmpeg_extracted_folder, "bin"))
        for file in os.listdir(os.path.join(ffmpeg_extracted_folder, "doc")):
            os.remove(os.path.join(ffmpeg_extracted_folder, "doc", file))
        for file in os.listdir(os.path.join(ffmpeg_extracted_folder, "presets")):
            os.remove(os.path.join(ffmpeg_extracted_folder, "presets", file))
        os.rmdir(os.path.join(ffmpeg_extracted_folder, "presets"))
        os.rmdir(os.path.join(ffmpeg_extracted_folder, "doc"))
        os.remove(os.path.join(ffmpeg_extracted_folder, "LICENSE"))
        os.remove(os.path.join(ffmpeg_extracted_folder, "README.txt"))
        os.rmdir(ffmpeg_extracted_folder)

        print(
            "FFmpeg installed successfully! Please restart your computer and then re-run the program."
        )
    except Exception as e:
        print(
            "An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again."
        )
        print(e)
        exit()


def ffmpeg_install_linux():
    try:
        subprocess.run(
            "sudo apt install ffmpeg",
            shell=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
    except Exception as e:
        print(
            "An error occurred while trying to install FFmpeg. Please try again. Otherwise, please install FFmpeg manually and try again."
        )
        print(e)
        exit()
    print("FFmpeg installed successfully! Please re-run the program.")
    exit()


def ffmpeg_install_mac():
    try:
        subprocess.run(
            "brew install ffmpeg",
            shell=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
    except FileNotFoundError:
        print(
            "Homebrew is not installed. Please install it and try again. Otherwise, please install FFmpeg manually and try again."
        )
        exit()
    print("FFmpeg installed successfully! Please re-run the program.")
    exit()


def ffmpeg_install():
    try:
        # Try to run the FFmpeg command
        subprocess.run(
            ["ffmpeg", "-version"],
            check=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
    except FileNotFoundError as e:
        # Check if there's ffmpeg.exe in the current directory
        if os.path.exists("./ffmpeg.exe"):
            print(
                "FFmpeg is installed on this system! If you are seeing this error for the second time, restart your computer."
            )
        print("FFmpeg is not installed on this system.")
        resp = input(
            "We can try to automatically install it for you. Would you like to do that? (y/n): "
        )
        if resp.lower() == "y":
            print("Installing FFmpeg...")
            if os.name == "nt":
                ffmpeg_install_windows()
            elif os.name == "posix":
                ffmpeg_install_linux()
            elif os.name == "mac":
                ffmpeg_install_mac()
            else:
                print("Your OS is not supported. Please install FFmpeg manually and try again.")
                exit()
        else:
            print("Please install FFmpeg manually and try again.")
            exit()
    except Exception as e:
        print(
            "Welcome fellow traveler! You're one of the few who have made it this far. We have no idea how you got at this error, but we're glad you're here. Please report this error to the developer, and we'll try to fix it as soon as possible. Thank you for your patience!"
        )
        print(e)
    return None
--------------------
fonts.py

from PIL.ImageFont import FreeTypeFont, ImageFont


def getsize(font: ImageFont | FreeTypeFont, text: str):
    left, top, right, bottom = font.getbbox(text)
    width = right - left
    height = bottom - top
    return width, height


def getheight(font: ImageFont | FreeTypeFont, text: str):
    _, height = getsize(font, text)
    return height
-------------------
gui_utils.py

import json
import re
from pathlib import Path

import toml
import tomlkit
from flask import flash


# Get validation checks from template
def get_checks():
    template = toml.load("utils/.config.template.toml")
    checks = {}

    def unpack_checks(obj: dict):
        for key in obj.keys():
            if "optional" in obj[key].keys():
                checks[key] = obj[key]
            else:
                unpack_checks(obj[key])

    unpack_checks(template)

    return checks


# Get current config (from config.toml) as dict
def get_config(obj: dict, done={}):
    for key in obj.keys():
        if not isinstance(obj[key], dict):
            done[key] = obj[key]
        else:
            get_config(obj[key], done)

    return done


# Checks if value is valid
def check(value, checks):
    incorrect = False

    if value == "False":
        value = ""

    if not incorrect and "type" in checks:
        try:
            value = eval(checks["type"])(value)
        except Exception:
            incorrect = True

    if (
        not incorrect and "options" in checks and value not in checks["options"]
    ):  # FAILSTATE Value is not one of the options
        incorrect = True
    if (
        not incorrect
        and "regex" in checks
        and (
            (isinstance(value, str) and re.match(checks["regex"], value) is None)
            or not isinstance(value, str)
        )
    ):  # FAILSTATE Value doesn't match regex, or has regex but is not a string.
        incorrect = True

    if (
        not incorrect
        and not hasattr(value, "__iter__")
        and (
            ("nmin" in checks and checks["nmin"] is not None and value < checks["nmin"])
            or ("nmax" in checks and checks["nmax"] is not None and value > checks["nmax"])
        )
    ):
        incorrect = True

    if (
        not incorrect
        and hasattr(value, "__iter__")
        and (
            ("nmin" in checks and checks["nmin"] is not None and len(value) < checks["nmin"])
            or ("nmax" in checks and checks["nmax"] is not None and len(value) > checks["nmax"])
        )
    ):
        incorrect = True

    if incorrect:
        return "Error"

    return value


# Modify settings (after form is submitted)
def modify_settings(data: dict, config_load, checks: dict):
    # Modify config settings
    def modify_config(obj: dict, name: str, value: any):
        for key in obj.keys():
            if name == key:
                obj[key] = value
            elif not isinstance(obj[key], dict):
                continue
            else:
                modify_config(obj[key], name, value)

    # Remove empty/incorrect key-value pairs
    data = {key: value for key, value in data.items() if value and key in checks.keys()}

    # Validate values
    for name in data.keys():
        value = check(data[name], checks[name])

        # Value is invalid
        if value == "Error":
            flash("Some values were incorrect and didn't save!", "error")
        else:
            # Value is valid
            modify_config(config_load, name, value)

    # Save changes in config.toml
    with Path("config.toml").open("w") as toml_file:
        toml_file.write(tomlkit.dumps(config_load))

    flash("Settings saved!")

    return get_config(config_load)


# Delete background video
def delete_background(key):
    # Read backgrounds.json
    with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds:
        data = json.load(backgrounds)

    # Remove background from backgrounds.json
    with open("utils/backgrounds.json", "w", encoding="utf-8") as backgrounds:
        if data.pop(key, None):
            json.dump(data, backgrounds, ensure_ascii=False, indent=4)
        else:
            flash("Couldn't find this background. Try refreshing the page.", "error")
            return

    # Remove background video from ".config.template.toml"
    config = tomlkit.loads(Path("utils/.config.template.toml").read_text())
    config["settings"]["background"]["background_choice"]["options"].remove(key)

    with Path("utils/.config.template.toml").open("w") as toml_file:
        toml_file.write(tomlkit.dumps(config))

    flash(f'Successfully removed "{key}" background!')


# Add background video
def add_background(youtube_uri, filename, citation, position):
    # Validate YouTube URI
    regex = re.compile(r"(?:\/|%3D|v=|vi=)([0-9A-z\-_]{11})(?:[%#?&]|$)").search(youtube_uri)

    if not regex:
        flash("YouTube URI is invalid!", "error")
        return

    youtube_uri = f"https://www.youtube.com/watch?v={regex.group(1)}"

    # Check if position is valid
    if position == "" or position == "center":
        position = "center"

    elif position.isdecimal():
        position = int(position)

    else:
        flash('Position is invalid! It can be "center" or decimal number.', "error")
        return

    # Sanitize filename
    regex = re.compile(r"^([a-zA-Z0-9\s_-]{1,100})$").match(filename)

    if not regex:
        flash("Filename is invalid!", "error")
        return

    filename = filename.replace(" ", "_")

    # Check if background doesn't already exist
    with open("utils/backgrounds.json", "r", encoding="utf-8") as backgrounds:
        data = json.load(backgrounds)

        # Check if key isn't already taken
        if filename in list(data.keys()):
            flash("Background video with this name already exist!", "error")
            return

        # Check if the YouTube URI isn't already used under different name
        if youtube_uri in [data[i][0] for i in list(data.keys())]:
            flash("Background video with this YouTube URI is already added!", "error")
            return

    # Add background video to json file
    with open("utils/backgrounds.json", "r+", encoding="utf-8") as backgrounds:
        data = json.load(backgrounds)

        data[filename] = [youtube_uri, filename + ".mp4", citation, position]
        backgrounds.seek(0)
        json.dump(data, backgrounds, ensure_ascii=False, indent=4)

    # Add background video to ".config.template.toml"
    config = tomlkit.loads(Path("utils/.config.template.toml").read_text())
    config["settings"]["background"]["background_choice"]["options"].append(filename)

    with Path("utils/.config.template.toml").open("w") as toml_file:
        toml_file.write(tomlkit.dumps(config))

    flash(f'Added "{citation}-{filename}.mp4" as a new background video!')

    return
--------------------
id.py

import re

from utils.console import print_substep


def id(reddit_obj: dict):
    """
    This function takes a reddit object and returns the post id
    """
    id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"])
    print_substep(f"Thread ID is {id}", style="bold blue")
    return id
----------------
imagenarator.py

import os
import re
import textwrap

from PIL import Image, ImageDraw, ImageFont
from rich.progress import track

from TTS.engine_wrapper import process_text
from utils.fonts import getheight, getsize


def draw_multiple_line_text(
    image, text, font, text_color, padding, wrap=50, transparent=False
) -> None:
    """
    Draw multiline text over given image
    """
    draw = ImageDraw.Draw(image)
    font_height = getheight(font, text)
    image_width, image_height = image.size
    lines = textwrap.wrap(text, width=wrap)
    y = (image_height / 2) - (((font_height + (len(lines) * padding) / len(lines)) * len(lines)) / 2)
    for line in lines:
        line_width, line_height = getsize(font, line)
        if transparent:
            shadowcolor = "black"
            for i in range(1, 5):
                draw.text(
                    ((image_width - line_width) / 2 - i, y - i),
                    line,
                    font=font,
                    fill=shadowcolor,
                )
                draw.text(
                    ((image_width - line_width) / 2 + i, y - i),
                    line,
                    font=font,
                    fill=shadowcolor,
                )
                draw.text(
                    ((image_width - line_width) / 2 - i, y + i),
                    line,
                    font=font,
                    fill=shadowcolor,
                )
                draw.text(
                    ((image_width - line_width) / 2 + i, y + i),
                    line,
                    font=font,
                    fill=shadowcolor,
                )
        draw.text(((image_width - line_width) / 2, y), line, font=font, fill=text_color)
        y += line_height + padding





def draw_single_word_text(image, word, font, text_color, padding, transparent=False) -> None:
    """
    Draw a single word centered on the given image.
    """
    draw = ImageDraw.Draw(image)
    image_width, image_height = image.size
    word_width, word_height = getsize(font, word)
    y = (image_height / 2) - (word_height / 2)
    x = (image_width / 2) - (word_width / 2)
    
    if transparent:
        shadowcolor = "black"
        for i in range(1, 5):
            draw.text((x - i, y - i), word, font=font, fill=shadowcolor)
            draw.text((x + i, y - i), word, font=font, fill=shadowcolor)
            draw.text((x - i, y + i), word, font=font, fill=shadowcolor)
            draw.text((x + i, y + i), word, font=font, fill=shadowcolor)
    
    draw.text((x, y), word, font=font, fill=text_color)






def imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) -> None:
    """
    Render Images for video
    """
    texts = reddit_obj["thread_post"]
    id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"])

    if transparent:
        font = ImageFont.truetype(os.path.join("fonts", "Roboto-Black.ttf"), 100)
    else:
        font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), 100)
    size = (1920, 1080)

    image = Image.new("RGBA", size, theme)

    for idx, text in track(enumerate(texts), "Rendering Image"):
        image = Image.new("RGBA", size, theme)
        text = process_text(text, False)
        draw_multiple_line_text(image, text, font, txtclr, padding, wrap=30, transparent=transparent)
        image.save(f"assets/temp/{id}/png/img{idx}.png")




# def imagemaker(theme, reddit_obj: dict, txtclr, padding=5, transparent=False) -> None:
#     """
#     Render Images for video with single-word captions.
#     """
#     texts = reddit_obj["thread_post"]  # This is a list of sentences/phrases
#     id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"])

#     # Load the font and increase its size
#     font_size = 150  # Adjust this value to make the font bigger
#     if transparent:
#         font = ImageFont.truetype(os.path.join("fonts", "Roboto-Black.ttf"), font_size)
#     else:
#         font = ImageFont.truetype(os.path.join("fonts", "Roboto-Regular.ttf"), font_size)

#     size = (1920, 1080)  # Image size
#     image = Image.new("RGBA", size, theme)

#     # Split the list of sentences into individual words
#     words = []
#     for sentence in texts:
#         words.extend(sentence.split())  # Split each sentence into words and extend the list

#     # Loop through each word to render an image for it
#     for idx, word in track(enumerate(words), "Rendering Image"):
#         image = Image.new("RGBA", size, theme)
#         processed_word = process_text(word, False)  # Process the word if needed
#         draw_single_word_text(image, processed_word, font, txtclr, padding, transparent=transparent)
#         image.save(f"assets/temp/{id}/png/img{idx}.png")
--------------------
playwright.py

def clear_cookie_by_name(context, cookie_cleared_name):
    cookies = context.cookies()
    filtered_cookies = [cookie for cookie in cookies if cookie["name"] != cookie_cleared_name]
    context.clear_cookies()
    context.add_cookies(filtered_cookies)
----------------------
posttextparser.py

import os
import re
import time
from typing import List

import spacy

from utils.console import print_step
from utils.voice import sanitize_text


# working good
def posttextparser(obj, *, tried: bool = False) -> List[str]:
    text: str = re.sub("\n", " ", obj)
    try:
        nlp = spacy.load("en_core_web_sm")
    except OSError as e:
        if not tried:
            os.system("python -m spacy download en_core_web_sm")
            time.sleep(5)
            return posttextparser(obj, tried=True)
        print_step(
            "The spacy model can't load. You need to install it with the command \npython -m spacy download en_core_web_sm "
        )
        raise e

    doc = nlp(text)

    newtext: list = []

    for line in doc.sents:
        if sanitize_text(line.text):
            newtext.append(line.text)

    return newtext
------------------------
settings.py

import re
from pathlib import Path
from typing import Dict, Tuple

import toml
from rich.console import Console

from utils.console import handle_input

console = Console()
config = dict  # autocomplete


def crawl(obj: dict, func=lambda x, y: print(x, y, end="\n"), path=None):
    if path is None:  # path Default argument value is mutable
        path = []
    for key in obj.keys():
        if type(obj[key]) is dict:
            crawl(obj[key], func, path + [key])
            continue
        func(path + [key], obj[key])


def check(value, checks, name):
    def get_check_value(key, default_result):
        return checks[key] if key in checks else default_result

    incorrect = False
    if value == {}:
        incorrect = True
    if not incorrect and "type" in checks:
        try:
            value = eval(checks["type"])(value)
        except:
            incorrect = True

    if (
        not incorrect and "options" in checks and value not in checks["options"]
    ):  # FAILSTATE Value is not one of the options
        incorrect = True
    if (
        not incorrect
        and "regex" in checks
        and (
            (isinstance(value, str) and re.match(checks["regex"], value) is None)
            or not isinstance(value, str)
        )
    ):  # FAILSTATE Value doesn't match regex, or has regex but is not a string.
        incorrect = True

    if (
        not incorrect
        and not hasattr(value, "__iter__")
        and (
            ("nmin" in checks and checks["nmin"] is not None and value < checks["nmin"])
            or ("nmax" in checks and checks["nmax"] is not None and value > checks["nmax"])
        )
    ):
        incorrect = True
    if (
        not incorrect
        and hasattr(value, "__iter__")
        and (
            ("nmin" in checks and checks["nmin"] is not None and len(value) < checks["nmin"])
            or ("nmax" in checks and checks["nmax"] is not None and len(value) > checks["nmax"])
        )
    ):
        incorrect = True

    if incorrect:
        value = handle_input(
            message=(
                (("[blue]Example: " + str(checks["example"]) + "\n") if "example" in checks else "")
                + "[red]"
                + ("Non-optional ", "Optional ")["optional" in checks and checks["optional"] is True]
            )
            + "[#C0CAF5 bold]"
            + str(name)
            + "[#F7768E bold]=",
            extra_info=get_check_value("explanation", ""),
            check_type=eval(get_check_value("type", "False")),
            default=get_check_value("default", NotImplemented),
            match=get_check_value("regex", ""),
            err_message=get_check_value("input_error", "Incorrect input"),
            nmin=get_check_value("nmin", None),
            nmax=get_check_value("nmax", None),
            oob_error=get_check_value(
                "oob_error", "Input out of bounds(Value too high/low/long/short)"
            ),
            options=get_check_value("options", None),
            optional=get_check_value("optional", False),
        )
    return value


def crawl_and_check(obj: dict, path: list, checks: dict = {}, name=""):
    if len(path) == 0:
        return check(obj, checks, name)
    if path[0] not in obj.keys():
        obj[path[0]] = {}
    obj[path[0]] = crawl_and_check(obj[path[0]], path[1:], checks, path[0])
    return obj


def check_vars(path, checks):
    global config
    crawl_and_check(config, path, checks)


def check_toml(template_file, config_file) -> Tuple[bool, Dict]:
    global config
    config = None
    try:
        template = toml.load(template_file)
    except Exception as error:
        console.print(f"[red bold]Encountered error when trying to to load {template_file}: {error}")
        return False
    try:
        config = toml.load(config_file)
    except toml.TomlDecodeError:
        console.print(
            f"""[blue]Couldn't read {config_file}.
Overwrite it?(y/n)"""
        )
        if not input().startswith("y"):
            print("Unable to read config, and not allowed to overwrite it. Giving up.")
            return False
        else:
            try:
                with open(config_file, "w") as f:
                    f.write("")
            except:
                console.print(
                    f"[red bold]Failed to overwrite {config_file}. Giving up.\nSuggestion: check {config_file} permissions for the user."
                )
                return False
    except FileNotFoundError:
        console.print(
            f"""[blue]Couldn't find {config_file}
Creating it now."""
        )
        try:
            with open(config_file, "x") as f:
                f.write("")
            config = {}
        except:
            console.print(
                f"[red bold]Failed to write to {config_file}. Giving up.\nSuggestion: check the folder's permissions for the user."
            )
            return False

    console.print(
        """\
[blue bold]###############################
#                             #
# Checking TOML configuration #
#                             #
###############################
If you see any prompts, that means that you have unset/incorrectly set variables, please input the correct values.\
"""
    )
    crawl(template, check_vars)
    with open(config_file, "w") as f:
        toml.dump(config, f)
    return config


if __name__ == "__main__":
    directory = Path().absolute()
    check_toml(f"{directory}/utils/.config.template.toml", "config.toml")
-------------------------------
subreddit.py

import json
from os.path import exists

from utils import settings
from utils.ai_methods import sort_by_similarity
from utils.console import print_substep


def get_subreddit_undone(submissions: list, subreddit, times_checked=0, similarity_scores=None):
    """_summary_

    Args:
        submissions (list): List of posts that are going to potentially be generated into a video
        subreddit (praw.Reddit.SubredditHelper): Chosen subreddit

    Returns:
        Any: The submission that has not been done
    """
    # Second try of getting a valid Submission
    if times_checked and settings.config["ai"]["ai_similarity_enabled"]:
        print("Sorting based on similarity for a different date filter and thread limit..")
        submissions = sort_by_similarity(
            submissions, keywords=settings.config["ai"]["ai_similarity_enabled"]
        )

    # recursively checks if the top submission in the list was already done.
    if not exists("./video_creation/data/videos.json"):
        with open("./video_creation/data/videos.json", "w+") as f:
            json.dump([], f)
    with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw:
        done_videos = json.load(done_vids_raw)
    for i, submission in enumerate(submissions):
        if already_done(done_videos, submission):
            continue
        if submission.over_18:
            try:
                if not settings.config["settings"]["allow_nsfw"]:
                    print_substep("NSFW Post Detected. Skipping...")
                    continue
            except AttributeError:
                print_substep("NSFW settings not defined. Skipping NSFW post...")
        if submission.stickied:
            print_substep("This post was pinned by moderators. Skipping...")
            continue
        if (
            submission.num_comments <= int(settings.config["reddit"]["thread"]["min_comments"])
            and not settings.config["settings"]["storymode"]
        ):
            print_substep(
                f'This post has under the specified minimum of comments ({settings.config["reddit"]["thread"]["min_comments"]}). Skipping...'
            )
            continue
        if settings.config["settings"]["storymode"]:
            if not submission.selftext:
                print_substep("You are trying to use story mode on post with no post text")
                continue
            else:
                # Check for the length of the post text
                if len(submission.selftext) > (
                    settings.config["settings"]["storymode_max_length"] or 2000
                ):
                    print_substep(
                        f"Post is too long ({len(submission.selftext)}), try with a different post. ({settings.config['settings']['storymode_max_length']} character limit)"
                    )
                    continue
                elif len(submission.selftext) < 30:
                    continue
        if settings.config["settings"]["storymode"] and not submission.is_self:
            continue
        if similarity_scores is not None:
            return submission, similarity_scores[i].item()
        return submission
    print("all submissions have been done going by top submission order")
    VALID_TIME_FILTERS = [
        "day",
        "hour",
        "month",
        "week",
        "year",
        "all",
    ]  # set doesn't have __getitem__
    index = times_checked + 1
    if index == len(VALID_TIME_FILTERS):
        print("All submissions have been done.")

    return get_subreddit_undone(
        subreddit.top(
            time_filter=VALID_TIME_FILTERS[index],
            limit=(50 if int(index) == 0 else index + 1 * 50),
        ),
        subreddit,
        times_checked=index,
    )  # all the videos in hot have already been done


def already_done(done_videos: list, submission) -> bool:
    """Checks to see if the given submission is in the list of videos

    Args:
        done_videos (list): Finished videos
        submission (Any): The submission

    Returns:
        Boolean: Whether the video was found in the list
    """

    for video in done_videos:
        if video["id"] == str(submission):
            return True
    return False
-----------------------
thumbnail.py

from PIL import ImageDraw, ImageFont


def create_thumbnail(thumbnail, font_family, font_size, font_color, width, height, title):
    font = ImageFont.truetype(font_family + ".ttf", font_size)
    Xaxis = width - (width * 0.2)  # 20% of the width
    sizeLetterXaxis = font_size * 0.5  # 50% of the font size
    XaxisLetterQty = round(Xaxis / sizeLetterXaxis)  # Quantity of letters that can fit in the X axis
    MarginYaxis = height * 0.12  # 12% of the height
    MarginXaxis = width * 0.05  # 5% of the width
    # 1.1 rem
    LineHeight = font_size * 1.1
    # rgb = "255,255,255" transform to list
    rgb = font_color.split(",")
    rgb = (int(rgb[0]), int(rgb[1]), int(rgb[2]))

    arrayTitle = []
    for word in title.split():
        if len(arrayTitle) == 0:
            # colocar a primeira palavra no arrayTitl# put the first word in the arrayTitle
            arrayTitle.append(word)
        else:
            # if the size of arrayTitle is less than qtLetters
            if len(arrayTitle[-1]) + len(word) < XaxisLetterQty:
                arrayTitle[-1] = arrayTitle[-1] + " " + word
            else:
                arrayTitle.append(word)

    draw = ImageDraw.Draw(thumbnail)
    # loop for put the title in the thumbnail
    for i in range(0, len(arrayTitle)):
        # 1.1 rem
        draw.text((MarginXaxis, MarginYaxis + (LineHeight * i)), arrayTitle[i], rgb, font=font)

    return thumbnail
-------------------------
version.py

import requests

from utils.console import print_step


def checkversion(__VERSION__: str):
    response = requests.get(
        "https://api.github.com/repos/elebumm/RedditVideoMakerBot/releases/latest"
    )
    latestversion = response.json()["tag_name"]
    if __VERSION__ == latestversion:
        print_step(f"You are using the newest version ({__VERSION__}) of the bot")
        return True
    elif __VERSION__ < latestversion:
        print_step(
            f"You are using an older version ({__VERSION__}) of the bot. Download the newest version ({latestversion}) from https://github.com/elebumm/RedditVideoMakerBot/releases/latest"
        )
    else:
        print_step(
            f"Welcome to the test version ({__VERSION__}) of the bot. Thanks for testing and feel free to report any bugs you find."
        )
------------------------
videos.py

import json
import time

from praw.models import Submission

from utils import settings
from utils.console import print_step


def check_done(
    redditobj: Submission,
) -> Submission:
    # don't set this to be run anyplace that isn't subreddit.py bc of inspect stack
    """Checks if the chosen post has already been generated

    Args:
        redditobj (Submission): Reddit object gotten from reddit/subreddit.py

    Returns:
        Submission|None: Reddit object in args
    """
    with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw:
        done_videos = json.load(done_vids_raw)
    for video in done_videos:
        if video["id"] == str(redditobj):
            if settings.config["reddit"]["thread"]["post_id"]:
                print_step(
                    "You already have done this video but since it was declared specifically in the config file the program will continue"
                )
                return redditobj
            print_step("Getting new post as the current one has already been done")
            return None
    return redditobj


def save_data(subreddit: str, filename: str, reddit_title: str, reddit_id: str, credit: str):
    """Saves the videos that have already been generated to a JSON file in video_creation/data/videos.json

    Args:
        filename (str): The finished video title name
        @param subreddit:
        @param filename:
        @param reddit_id:
        @param reddit_title:
    """
    with open("./video_creation/data/videos.json", "r+", encoding="utf-8") as raw_vids:
        done_vids = json.load(raw_vids)
        if reddit_id in [video["id"] for video in done_vids]:
            return  # video already done but was specified to continue anyway in the config file
        payload = {
            "subreddit": subreddit,
            "id": reddit_id,
            "time": str(int(time.time())),
            "background_credit": credit,
            "reddit_title": reddit_title,
            "filename": filename,
        }
        done_vids.append(payload)
        raw_vids.seek(0)
        json.dump(done_vids, raw_vids, ensure_ascii=False, indent=4)
---------------------
voice.py

import re
import sys
import time as pytime
from datetime import datetime
from time import sleep

from cleantext import clean
from requests import Response

from utils import settings

if sys.version_info[0] >= 3:
    from datetime import timezone


def check_ratelimit(response: Response) -> bool:
    """
    Checks if the response is a ratelimit response.
    If it is, it sleeps for the time specified in the response.
    """
    if response.status_code == 429:
        try:
            time = int(response.headers["X-RateLimit-Reset"])
            print(f"Ratelimit hit. Sleeping for {time - int(pytime.time())} seconds.")
            sleep_until(time)
            return False
        except KeyError:  # if the header is not present, we don't know how long to wait
            return False

    return True


def sleep_until(time) -> None:
    """
    Pause your program until a specific end time.
    'time' is either a valid datetime object or unix timestamp in seconds (i.e. seconds since Unix epoch)
    """
    end = time

    # Convert datetime to unix timestamp and adjust for locality
    if isinstance(time, datetime):
        # If we're on Python 3 and the user specified a timezone, convert to UTC and get the timestamp.
        if sys.version_info[0] >= 3 and time.tzinfo:
            end = time.astimezone(timezone.utc).timestamp()
        else:
            zoneDiff = pytime.time() - (datetime.now() - datetime(1970, 1, 1)).total_seconds()
            end = (time - datetime(1970, 1, 1)).total_seconds() + zoneDiff

    # Type check
    if not isinstance(end, (int, float)):
        raise Exception("The time parameter is not a number or datetime object")

    # Now we wait
    while True:
        now = pytime.time()
        diff = end - now

        #
        # Time is up!
        #
        if diff <= 0:
            break
        else:
            # 'logarithmic' sleeping to minimize loop iterations
            sleep(diff / 2)


def sanitize_text(text: str) -> str:
    r"""Sanitizes the text for tts.
        What gets removed:
     - following characters`^_~@!&;#:-%“”‘"%*/{}[]()\|<>?=+`
     - any http or https links

    Args:
        text (str): Text to be sanitized

    Returns:
        str: Sanitized text
    """

    # remove any urls from the text
    regex_urls = r"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*"

    result = re.sub(regex_urls, " ", text)

    # note: not removing apostrophes
    regex_expr = r"\s['|’]|['|’]\s|[\^_~@!&;#:\-%—“”‘\"%\*/{}\[\]\(\)\\|<>=+]"
    result = re.sub(regex_expr, " ", result)
    result = result.replace("+", "plus").replace("&", "and")

    # emoji removal if the setting is enabled
    if settings.config["settings"]["tts"]["no_emojis"]:
        result = clean(result, no_emoji=True)

    # remove extra whitespace
    return " ".join(result.split())
------------------
video_creation
-data
cookie-dark-mode.json
cookie-dark-mode.json
-----------------
_inti_.py
-------------------------
background.py

import json
import random
import re
from pathlib import Path
from random import randrange
from typing import Any, Dict, Tuple

import yt_dlp
from moviepy.editor import AudioFileClip, VideoFileClip
from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip

from utils import settings
from utils.console import print_step, print_substep


def load_background_options():
    background_options = {}
    # Load background videos
    with open("./utils/background_videos.json") as json_file:
        background_options["video"] = json.load(json_file)

    # Load background audios
    with open("./utils/background_audios.json") as json_file:
        background_options["audio"] = json.load(json_file)

    # Remove "__comment" from backgrounds
    del background_options["video"]["__comment"]
    del background_options["audio"]["__comment"]

    for name in list(background_options["video"].keys()):
        pos = background_options["video"][name][3]

        if pos != "center":
            background_options["video"][name][3] = lambda t: ("center", pos + t)

    return background_options


def get_start_and_end_times(video_length: int, length_of_clip: int) -> Tuple[int, int]:
    """Generates a random interval of time to be used as the background of the video.

    Args:
        video_length (int): Length of the video
        length_of_clip (int): Length of the video to be used as the background

    Returns:
        tuple[int,int]: Start and end time of the randomized interval
    """
    initialValue = 180
    # Issue #1649 - Ensures that will be a valid interval in the video
    while int(length_of_clip) <= int(video_length + initialValue):
        if initialValue == initialValue // 2:
            raise Exception("Your background is too short for this video length")
        else:
            initialValue //= 2  # Divides the initial value by 2 until reach 0
    random_time = randrange(initialValue, int(length_of_clip) - int(video_length))
    return random_time, random_time + video_length


def get_background_config(mode: str):
    """Fetch the background/s configuration"""
    try:
        choice = str(settings.config["settings"]["background"][f"background_{mode}"]).casefold()
    except AttributeError:
        print_substep("No background selected. Picking random background'")
        choice = None

    # Handle default / not supported background using default option.
    # Default : pick random from supported background.
    if not choice or choice not in background_options[mode]:
        choice = random.choice(list(background_options[mode].keys()))

    return background_options[mode][choice]


def download_background_video(background_config: Tuple[str, str, str, Any]):
    """Downloads the background/s video from YouTube."""
    Path("./assets/backgrounds/video/").mkdir(parents=True, exist_ok=True)
    # note: make sure the file name doesn't include an - in it
    uri, filename, credit, _ = background_config
    if Path(f"assets/backgrounds/video/{credit}-{filename}").is_file():
        return
    print_step(
        "We need to download the backgrounds videos. they are fairly large but it's only done once. 😎"
    )
    print_substep("Downloading the backgrounds videos... please be patient 🙏 ")
    print_substep(f"Downloading {filename} from {uri}")
    ydl_opts = {
        "format": "bestvideo[height<=1080][ext=mp4]",
        "outtmpl": f"assets/backgrounds/video/{credit}-{filename}",
        "retries": 10,
    }

    with yt_dlp.YoutubeDL(ydl_opts) as ydl:
        ydl.download(uri)
    print_substep("Background video downloaded successfully! 🎉", style="bold green")


def download_background_audio(background_config: Tuple[str, str, str]):
    """Downloads the background/s audio from YouTube."""
    Path("./assets/backgrounds/audio/").mkdir(parents=True, exist_ok=True)
    # note: make sure the file name doesn't include an - in it
    uri, filename, credit = background_config
    if Path(f"assets/backgrounds/audio/{credit}-{filename}").is_file():
        return
    print_step(
        "We need to download the backgrounds audio. they are fairly large but it's only done once. 😎"
    )
    print_substep("Downloading the backgrounds audio... please be patient 🙏 ")
    print_substep(f"Downloading {filename} from {uri}")
    ydl_opts = {
        "outtmpl": f"./assets/backgrounds/audio/{credit}-{filename}",
        "format": "bestaudio/best",
        "extract_audio": True,
    }

    with yt_dlp.YoutubeDL(ydl_opts) as ydl:
        ydl.download([uri])

    print_substep("Background audio downloaded successfully! 🎉", style="bold green")


def chop_background(background_config: Dict[str, Tuple], video_length: int, reddit_object: dict):
    """Generates the background audio and footage to be used in the video and writes it to assets/temp/background.mp3 and assets/temp/background.mp4

    Args:
        background_config (Dict[str,Tuple]]) : Current background configuration
        video_length (int): Length of the clip where the background footage is to be taken out of
    """
    id = re.sub(r"[^\w\s-]", "", reddit_object["thread_id"])

    if settings.config["settings"]["background"][f"background_audio_volume"] == 0:
        print_step("Volume was set to 0. Skipping background audio creation . . .")
    else:
        print_step("Finding a spot in the backgrounds audio to chop...✂️")
        audio_choice = f"{background_config['audio'][2]}-{background_config['audio'][1]}"
        background_audio = AudioFileClip(f"assets/backgrounds/audio/{audio_choice}")
        start_time_audio, end_time_audio = get_start_and_end_times(
            video_length, background_audio.duration
        )
        background_audio = background_audio.subclip(start_time_audio, end_time_audio)
        background_audio.write_audiofile(f"assets/temp/{id}/background.mp3")

    print_step("Finding a spot in the backgrounds video to chop...✂️")
    video_choice = f"{background_config['video'][2]}-{background_config['video'][1]}"
    background_video = VideoFileClip(f"assets/backgrounds/video/{video_choice}")
    start_time_video, end_time_video = get_start_and_end_times(
        video_length, background_video.duration
    )
    # Extract video subclip
    try:
        ffmpeg_extract_subclip(
            f"assets/backgrounds/video/{video_choice}",
            start_time_video,
            end_time_video,
            targetname=f"assets/temp/{id}/background.mp4",
        )
    except (OSError, IOError):  # ffmpeg issue see #348
        print_substep("FFMPEG issue. Trying again...")
        with VideoFileClip(f"assets/backgrounds/video/{video_choice}") as video:
            new = video.subclip(start_time_video, end_time_video)
            new.write_videofile(f"assets/temp/{id}/background.mp4")
    print_substep("Background video chopped successfully!", style="bold green")
    return background_config["video"][2]


# Create a tuple for downloads background (background_audio_options, background_video_options)
background_options = load_background_options()
-----------------
final_video.py

import multiprocessing
import os
import re
import tempfile
import textwrap
import threading
import time
from os.path import exists  # Needs to be imported specifically
from pathlib import Path
from typing import Dict, Final, Tuple

import ffmpeg
import translators
from PIL import Image, ImageDraw, ImageFont
from rich.console import Console
from rich.progress import track

from utils import settings
from utils.cleanup import cleanup
from utils.console import print_step, print_substep
from utils.fonts import getheight
from utils.thumbnail import create_thumbnail
from utils.videos import save_data

console = Console()


class ProgressFfmpeg(threading.Thread):
    def __init__(self, vid_duration_seconds, progress_update_callback):
        threading.Thread.__init__(self, name="ProgressFfmpeg")
        self.stop_event = threading.Event()
        self.output_file = tempfile.NamedTemporaryFile(mode="w+", delete=False)
        self.vid_duration_seconds = vid_duration_seconds
        self.progress_update_callback = progress_update_callback

    def run(self):
        while not self.stop_event.is_set():
            latest_progress = self.get_latest_ms_progress()
            if latest_progress is not None:
                completed_percent = latest_progress / self.vid_duration_seconds
                self.progress_update_callback(completed_percent)
            time.sleep(1)

    def get_latest_ms_progress(self):
        lines = self.output_file.readlines()

        if lines:
            for line in lines:
                if "out_time_ms" in line:
                    out_time_ms_str = line.split("=")[1].strip()
                    if out_time_ms_str.isnumeric():
                        return float(out_time_ms_str) / 1000000.0
                    else:
                        # Handle the case when "N/A" is encountered
                        return None
        return None

    def stop(self):
        self.stop_event.set()

    def __enter__(self):
        self.start()
        return self

    def __exit__(self, *args, **kwargs):
        self.stop()


def name_normalize(name: str) -> str:
    name = re.sub(r'[?\\"%*:|<>]', "", name)
    name = re.sub(r"( [w,W]\s?\/\s?[o,O,0])", r" without", name)
    name = re.sub(r"( [w,W]\s?\/)", r" with", name)
    name = re.sub(r"(\d+)\s?\/\s?(\d+)", r"\1 of \2", name)
    name = re.sub(r"(\w+)\s?\/\s?(\w+)", r"\1 or \2", name)
    name = re.sub(r"\/", r"", name)

    lang = settings.config["reddit"]["thread"]["post_lang"]
    if lang:
        print_substep("Translating filename...")
        translated_name = translators.translate_text(name, translator="google", to_language=lang)
        return translated_name
    else:
        return name


def prepare_background(reddit_id: str, W: int, H: int) -> str:
    output_path = f"assets/temp/{reddit_id}/background_noaudio.mp4"
    output = (
        ffmpeg.input(f"assets/temp/{reddit_id}/background.mp4")
        .filter("crop", f"ih*({W}/{H})", "ih")
        .output(
            output_path,
            an=None,
            **{
                "c:v": "h264",
                "b:v": "20M",
                "b:a": "192k",
                "threads": multiprocessing.cpu_count(),
            },
        )
        .overwrite_output()
    )
    try:
        output.run(quiet=True)
    except ffmpeg.Error as e:
        print(e.stderr.decode("utf8"))
        exit(1)
    return output_path


def create_fancy_thumbnail(image, text, text_color, padding, wrap=35):
    print_step(f"Creating fancy thumbnail for: {text}")
    font_title_size = 47
    font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)
    image_width, image_height = image.size
    lines = textwrap.wrap(text, width=wrap)
    y = (
        (image_height / 2)
        - (((getheight(font, text) + (len(lines) * padding) / len(lines)) * len(lines)) / 2)
        + 30
    )
    draw = ImageDraw.Draw(image)

    username_font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), 30)
    draw.text(
        (205, 825),
        settings.config["settings"]["channel_name"],
        font=username_font,
        fill=text_color,
        align="left",
    )

    if len(lines) == 3:
        lines = textwrap.wrap(text, width=wrap + 10)
        font_title_size = 40
        font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)
        y = (
            (image_height / 2)
            - (((getheight(font, text) + (len(lines) * padding) / len(lines)) * len(lines)) / 2)
            + 35
        )
    elif len(lines) == 4:
        lines = textwrap.wrap(text, width=wrap + 10)
        font_title_size = 35
        font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)
        y = (
            (image_height / 2)
            - (((getheight(font, text) + (len(lines) * padding) / len(lines)) * len(lines)) / 2)
            + 40
        )
    elif len(lines) > 4:
        lines = textwrap.wrap(text, width=wrap + 10)
        font_title_size = 30
        font = ImageFont.truetype(os.path.join("fonts", "Roboto-Bold.ttf"), font_title_size)
        y = (
            (image_height / 2)
            - (((getheight(font, text) + (len(lines) * padding) / len(lines)) * len(lines)) / 2)
            + 30
        )

    for line in lines:
        draw.text((120, y), line, font=font, fill=text_color, align="left")
        y += getheight(font, line) + padding

    return image


def merge_background_audio(audio: ffmpeg, reddit_id: str):
    """Gather an audio and merge with assets/backgrounds/background.mp3
    Args:
        audio (ffmpeg): The TTS final audio but without background.
        reddit_id (str): The ID of subreddit
    """
    background_audio_volume = settings.config["settings"]["background"]["background_audio_volume"]
    if background_audio_volume == 0:
        return audio  # Return the original audio
    else:
        # sets volume to config
        bg_audio = ffmpeg.input(f"assets/temp/{reddit_id}/background.mp3").filter(
            "volume",
            background_audio_volume,
        )
        # Merges audio and background_audio
        merged_audio = ffmpeg.filter([audio, bg_audio], "amix", duration="longest")
        return merged_audio  # Return merged audio


def make_final_video(
    number_of_clips: int,
    length: int,
    reddit_obj: dict,
    background_config: Dict[str, Tuple],
):
    """Gathers audio clips, gathers all screenshots, stitches them together and saves the final video to assets/temp
    Args:
        number_of_clips (int): Index to end at when going through the screenshots'
        length (int): Length of the video
        reddit_obj (dict): The reddit object that contains the posts to read.
        background_config (Tuple[str, str, str, Any]): The background config to use.
    """
    # settings values
    W: Final[int] = int(settings.config["settings"]["resolution_w"])
    H: Final[int] = int(settings.config["settings"]["resolution_h"])

    opacity = settings.config["settings"]["opacity"]

    reddit_id = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"])

    allowOnlyTTSFolder: bool = (
        settings.config["settings"]["background"]["enable_extra_audio"]
        and settings.config["settings"]["background"]["background_audio_volume"] != 0
    )

    print_step("Creating the final video 🎥")

    background_clip = ffmpeg.input(prepare_background(reddit_id, W=W, H=H))

    # Gather all audio clips
    audio_clips = list()
    if number_of_clips == 0 and settings.config["settings"]["storymode"] == "false":
        print(
            "No audio clips to gather. Please use a different TTS or post."
        )  # This is to fix the TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'
        exit()
    if settings.config["settings"]["storymode"]:
        if settings.config["settings"]["storymodemethod"] == 0:
            audio_clips = [ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3")]
            audio_clips.insert(1, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/postaudio.mp3"))
        elif settings.config["settings"]["storymodemethod"] == 1:
            audio_clips = [
                ffmpeg.input(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")
                for i in track(range(number_of_clips + 1), "Collecting the audio files...")
            ]
            audio_clips.insert(0, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3"))

    else:
        audio_clips = [
            ffmpeg.input(f"assets/temp/{reddit_id}/mp3/{i}.mp3") for i in range(number_of_clips)
        ]
        audio_clips.insert(0, ffmpeg.input(f"assets/temp/{reddit_id}/mp3/title.mp3"))

        audio_clips_durations = [
            float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/{i}.mp3")["format"]["duration"])
            for i in range(number_of_clips)
        ]
        audio_clips_durations.insert(
            0,
            float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/title.mp3")["format"]["duration"]),
        )
    audio_concat = ffmpeg.concat(*audio_clips, a=1, v=0)
    ffmpeg.output(
        audio_concat, f"assets/temp/{reddit_id}/audio.mp3", **{"b:a": "192k"}
    ).overwrite_output().run(quiet=True)

    console.log(f"[bold green] Video Will Be: {length} Seconds Long")

    screenshot_width = int((W * 45) // 100)
    audio = ffmpeg.input(f"assets/temp/{reddit_id}/audio.mp3")
    final_audio = merge_background_audio(audio, reddit_id)

    image_clips = list()

    Path(f"assets/temp/{reddit_id}/png").mkdir(parents=True, exist_ok=True)

    # Credits to tim (beingbored)
    # get the title_template image and draw a text in the middle part of it with the title of the thread
    title_template = Image.open("assets/title_template.png")

    title = reddit_obj["thread_title"]

    title = name_normalize(title)

    font_color = "#000000"
    padding = 5

    # create_fancy_thumbnail(image, text, text_color, padding
    title_img = create_fancy_thumbnail(title_template, title, font_color, padding)

    title_img.save(f"assets/temp/{reddit_id}/png/title.png")
    image_clips.insert(
        0,
        ffmpeg.input(f"assets/temp/{reddit_id}/png/title.png")["v"].filter(
            "scale", screenshot_width, -1
        ),
    )

    current_time = 0
    if settings.config["settings"]["storymode"]:
        audio_clips_durations = [
            float(
                ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/postaudio-{i}.mp3")["format"]["duration"]
            )
            for i in range(number_of_clips)
        ]
        audio_clips_durations.insert(
            0,
            float(ffmpeg.probe(f"assets/temp/{reddit_id}/mp3/title.mp3")["format"]["duration"]),
        )
         # Create a transparent image for other clips
        transparent_image = Image.new('RGBA', (screenshot_width, screenshot_width), (0, 0, 0, 0))
        transparent_image.save(f"assets/temp/{reddit_id}/png/transparent.png")
    
        if settings.config["settings"]["storymodemethod"] == 0:
            image_clips.insert(
                1,
                ffmpeg.input(f"assets/temp/{reddit_id}/png/story_content.png").filter(
                    "scale", screenshot_width, -1
                ),
            )
            background_clip = background_clip.overlay(
                image_clips[0],
                enable=f"between(t,{current_time},{current_time + audio_clips_durations[0]})",
                x="(main_w-overlay_w)/2",
                y="(main_h-overlay_h)/2",
            )
            current_time += audio_clips_durations[0]
        elif settings.config["settings"]["storymodemethod"] == 1:
            for i in track(range(0, number_of_clips + 1), "Collecting the image files..."):
                 # Create a transparent image for other clips
                transparent_image = Image.new('RGBA', (screenshot_width, screenshot_width), (0, 0, 0, 0))
                transparent_image.save(f"assets/temp/{reddit_id}/png/trs{i}.png")

                image_clips.append(
                    ffmpeg.input(f"assets/temp/{reddit_id}/png/trs{i}.png")["v"].filter(
                        "scale", screenshot_width, -1
                    )
                )
                background_clip = background_clip.overlay(
                    image_clips[i],
                    enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})",
                    x="(main_w-overlay_w)/2",
                    y="(main_h-overlay_h)/2",
                )
                current_time += audio_clips_durations[i]
    else:
        for i in range(0, number_of_clips + 1):
            image_clips.append(
                ffmpeg.input(f"assets/temp/{reddit_id}/png/comment_{i}.png")["v"].filter(
                    "scale", screenshot_width, -1
                )
            )
            image_overlay = image_clips[i].filter("colorchannelmixer", aa=opacity)
            assert (
                audio_clips_durations is not None
            ), "Please make a GitHub issue if you see this. Ping @JasonLovesDoggo on GitHub."
            background_clip = background_clip.overlay(
                image_overlay,
                enable=f"between(t,{current_time},{current_time + audio_clips_durations[i]})",
                x="(main_w-overlay_w)/2",
                y="(main_h-overlay_h)/2",
            )
            current_time += audio_clips_durations[i]

    title = re.sub(r"[^\w\s-]", "", reddit_obj["thread_title"])
    idx = re.sub(r"[^\w\s-]", "", reddit_obj["thread_id"])
    title_thumb = reddit_obj["thread_title"]

    filename = f"{name_normalize(title)[:251]}"
    subreddit = settings.config["reddit"]["thread"]["subreddit"]

    if not exists(f"./results/{subreddit}"):
        print_substep("The 'results' folder could not be found so it was automatically created.")
        os.makedirs(f"./results/{subreddit}")

    if not exists(f"./results/{subreddit}/OnlyTTS") and allowOnlyTTSFolder:
        print_substep("The 'OnlyTTS' folder could not be found so it was automatically created.")
        os.makedirs(f"./results/{subreddit}/OnlyTTS")

    # create a thumbnail for the video
    settingsbackground = settings.config["settings"]["background"]

    if settingsbackground["background_thumbnail"]:
        if not exists(f"./results/{subreddit}/thumbnails"):
            print_substep(
                "The 'results/thumbnails' folder could not be found so it was automatically created."
            )
            os.makedirs(f"./results/{subreddit}/thumbnails")
        # get the first file with the .png extension from assets/backgrounds and use it as a background for the thumbnail
        first_image = next(
            (file for file in os.listdir("assets/backgrounds") if file.endswith(".png")),
            None,
        )
        if first_image is None:
            print_substep("No png files found in assets/backgrounds", "red")

        else:
            font_family = settingsbackground["background_thumbnail_font_family"]
            font_size = settingsbackground["background_thumbnail_font_size"]
            font_color = settingsbackground["background_thumbnail_font_color"]
            thumbnail = Image.open(f"assets/backgrounds/{first_image}")
            width, height = thumbnail.size
            thumbnailSave = create_thumbnail(
                thumbnail,
                font_family,
                font_size,
                font_color,
                width,
                height,
                title_thumb,
            )
            thumbnailSave.save(f"./assets/temp/{reddit_id}/thumbnail.png")
            print_substep(f"Thumbnail - Building Thumbnail in assets/temp/{reddit_id}/thumbnail.png")

    text = f" " #Removed mmention of bacground creator
    background_clip = ffmpeg.drawtext(
        background_clip,
        text=text,
        x=f"(w-text_w)",
        y=f"(h-text_h)",
        fontsize=5,
        fontcolor="White",
        fontfile=os.path.join("fonts", "Roboto-Regular.ttf"),
    )
    background_clip = background_clip.filter("scale", W, H)
    print_step("Rendering the video 🎥")
    from tqdm import tqdm

    pbar = tqdm(total=100, desc="Progress: ", bar_format="{l_bar}{bar}", unit=" %")

    def on_update_example(progress) -> None:
        status = round(progress * 100, 2)
        old_percentage = pbar.n
        pbar.update(status - old_percentage)

    defaultPath = f"results/{subreddit}"
    with ProgressFfmpeg(length, on_update_example) as progress:
        path = defaultPath + f"/{filename}"
        path = (
            path[:251] + ".mp4"
        )  # Prevent a error by limiting the path length, do not change this.
        try:
            ffmpeg.output(
                background_clip,
                final_audio,
                path,
                f="mp4",
                **{
                    "c:v": "h264",
                    "b:v": "20M",
                    "b:a": "192k",
                    "threads": multiprocessing.cpu_count(),
                },
            ).overwrite_output().global_args("-progress", progress.output_file.name).run(
                quiet=True,
                overwrite_output=True,
                capture_stdout=False,
                capture_stderr=False,
            )
        except ffmpeg.Error as e:
            print(e.stderr.decode("utf8"))
            exit(1)
    old_percentage = pbar.n
    pbar.update(100 - old_percentage)
    if allowOnlyTTSFolder:
        path = defaultPath + f"/OnlyTTS/{filename}"
        path = (
            path[:251] + ".mp4"
        )  # Prevent a error by limiting the path length, do not change this.
        print_step("Rendering the Only TTS Video 🎥")
        with ProgressFfmpeg(length, on_update_example) as progress:
            try:
                ffmpeg.output(
                    background_clip,
                    audio,
                    path,
                    f="mp4",
                    **{
                        "c:v": "h264",
                        "b:v": "20M",
                        "b:a": "192k",
                        "threads": multiprocessing.cpu_count(),
                    },
                ).overwrite_output().global_args("-progress", progress.output_file.name).run(
                    quiet=True,
                    overwrite_output=True,
                    capture_stdout=False,
                    capture_stderr=False,
                )
            except ffmpeg.Error as e:
                print(e.stderr.decode("utf8"))
                exit(1)

        old_percentage = pbar.n
        pbar.update(100 - old_percentage)
    pbar.close()
    save_data(subreddit, filename + ".mp4", title, idx, background_config["video"][2])
    print_step("Removing temporary files 🗑")
    cleanups = cleanup(reddit_id)
    print_substep(f"Removed {cleanups} temporary files 🗑")
    print_step("Done! 🎉 The video is in the results folder 📁")
-----------------------
screenshot_dowloader.py

import json
import re
from pathlib import Path
from typing import Dict, Final

import translators
from playwright.sync_api import ViewportSize, sync_playwright
from rich.progress import track

from utils import settings
from utils.console import print_step, print_substep
from utils.imagenarator import imagemaker
from utils.playwright import clear_cookie_by_name
from utils.videos import save_data

__all__ = ["get_screenshots_of_reddit_posts"]


def get_screenshots_of_reddit_posts(reddit_object: dict, screenshot_num: int):
    """Downloads screenshots of reddit posts as seen on the web. Downloads to assets/temp/png

    Args:
        reddit_object (Dict): Reddit object received from reddit/subreddit.py
        screenshot_num (int): Number of screenshots to download
    """
    # settings values
    W: Final[int] = int(settings.config["settings"]["resolution_w"])
    H: Final[int] = int(settings.config["settings"]["resolution_h"])
    lang: Final[str] = settings.config["reddit"]["thread"]["post_lang"]
    storymode: Final[bool] = settings.config["settings"]["storymode"]

    print_step("Downloading screenshots of reddit posts...")
    reddit_id = re.sub(r"[^\w\s-]", "", reddit_object["thread_id"])
    # ! Make sure the reddit screenshots folder exists
    Path(f"assets/temp/{reddit_id}/png").mkdir(parents=True, exist_ok=True)

    # set the theme and disable non-essential cookies
    if settings.config["settings"]["theme"] == "dark":
        cookie_file = open("./video_creation/data/cookie-dark-mode.json", encoding="utf-8")
        bgcolor = (33, 33, 36, 255)
        txtcolor = (240, 240, 240)
        transparent = False
    elif settings.config["settings"]["theme"] == "transparent":
        if storymode:
            # Transparent theme
            bgcolor = (0, 0, 0, 0)
            txtcolor = (255, 255, 255)
            transparent = True
            cookie_file = open("./video_creation/data/cookie-dark-mode.json", encoding="utf-8")
        else:
            # Switch to dark theme
            cookie_file = open("./video_creation/data/cookie-dark-mode.json", encoding="utf-8")
            bgcolor = (33, 33, 36, 255)
            txtcolor = (240, 240, 240)
            transparent = False
    else:
        cookie_file = open("./video_creation/data/cookie-light-mode.json", encoding="utf-8")
        bgcolor = (255, 255, 255, 255)
        txtcolor = (0, 0, 0)
        transparent = False

    if storymode and settings.config["settings"]["storymodemethod"] == 1:
        # for idx,item in enumerate(reddit_object["thread_post"]):
        print_substep("Generating images...")
        return imagemaker(
            theme=bgcolor,
            reddit_obj=reddit_object,
            txtclr=txtcolor,
            transparent=transparent,
        )

    screenshot_num: int
    with sync_playwright() as p:
        print_substep("Launching Headless Browser...")

        browser = p.chromium.launch(
            headless=True
        )  # headless=False will show the browser for debugging purposes
        # Device scale factor (or dsf for short) allows us to increase the resolution of the screenshots
        # When the dsf is 1, the width of the screenshot is 600 pixels
        # so we need a dsf such that the width of the screenshot is greater than the final resolution of the video
        dsf = (W // 600) + 1

        context = browser.new_context(
            locale=lang or "en-us",
            color_scheme="dark",
            viewport=ViewportSize(width=W, height=H),
            device_scale_factor=dsf,
            user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
        )
        cookies = json.load(cookie_file)
        cookie_file.close()

        context.add_cookies(cookies)  # load preference cookies

        # Login to Reddit
        print_substep("Logging in to Reddit...")
        page = context.new_page()
        page.goto("https://www.reddit.com/login", timeout=0)
        page.set_viewport_size(ViewportSize(width=1920, height=1080))
        page.wait_for_load_state()

        page.locator(f'input[name="username"]').fill(settings.config["reddit"]["creds"]["username"])
        page.locator(f'input[name="password"]').fill(settings.config["reddit"]["creds"]["password"])
        page.get_by_role("button", name="Log In").click()
        page.wait_for_timeout(5000)

        login_error_div = page.locator(".AnimatedForm__errorMessage").first
        if login_error_div.is_visible():
            login_error_message = login_error_div.inner_text()
            if login_error_message.strip() == "":
                # The div element is empty, no error
                pass
            else:
                # The div contains an error message
                print_substep(
                    "Your reddit credentials are incorrect! Please modify them accordingly in the config.toml file.",
                    style="red",
                )
                exit()
        else:
            pass

        page.wait_for_load_state()
        # Handle the redesign
        # Check if the redesign optout cookie is set
        if page.locator("#redesign-beta-optin-btn").is_visible():
            # Clear the redesign optout cookie
            clear_cookie_by_name(context, "redesign_optout")
            # Reload the page for the redesign to take effect
            page.reload()
        # Get the thread screenshot
        page.goto(reddit_object["thread_url"], timeout=0)
        page.set_viewport_size(ViewportSize(width=W, height=H))
        page.wait_for_load_state()
        page.wait_for_timeout(5000)

        if page.locator(
            "#t3_12hmbug > div > div._3xX726aBn29LDbsDtzr_6E._1Ap4F5maDtT1E1YuCiaO0r.D3IL3FD0RFy_mkKLPwL4 > div > div > button"
        ).is_visible():
            # This means the post is NSFW and requires to click the proceed button.

            print_substep("Post is NSFW. You are spicy...")
            page.locator(
                "#t3_12hmbug > div > div._3xX726aBn29LDbsDtzr_6E._1Ap4F5maDtT1E1YuCiaO0r.D3IL3FD0RFy_mkKLPwL4 > div > div > button"
            ).click()
            page.wait_for_load_state()  # Wait for page to fully load

            # translate code
        if page.locator(
            "#SHORTCUT_FOCUSABLE_DIV > div:nth-child(7) > div > div > div > header > div > div._1m0iFpls1wkPZJVo38-LSh > button > i"
        ).is_visible():
            page.locator(
                "#SHORTCUT_FOCUSABLE_DIV > div:nth-child(7) > div > div > div > header > div > div._1m0iFpls1wkPZJVo38-LSh > button > i"
            ).click()  # Interest popup is showing, this code will close it

        if lang:
            print_substep("Translating post...")
            texts_in_tl = translators.translate_text(
                reddit_object["thread_title"],
                to_language=lang,
                translator="google",
            )

            page.evaluate(
                "tl_content => document.querySelector('[data-adclicklocation=\"title\"] > div > div > h1').textContent = tl_content",
                texts_in_tl,
            )
        else:
            print_substep("Skipping translation...")

        postcontentpath = f"assets/temp/{reddit_id}/png/title.png"
        try:
            if settings.config["settings"]["zoom"] != 1:
                # store zoom settings
                zoom = settings.config["settings"]["zoom"]
                # zoom the body of the page
                page.evaluate("document.body.style.zoom=" + str(zoom))
                # as zooming the body doesn't change the properties of the divs, we need to adjust for the zoom
                location = page.locator('[data-test-id="post-content"]').bounding_box()
                for i in location:
                    location[i] = float("{:.2f}".format(location[i] * zoom))
                page.screenshot(clip=location, path=postcontentpath)
            else:
                page.locator('[data-test-id="post-content"]').screenshot(path=postcontentpath)
        except Exception as e:
            print_substep("Something went wrong!", style="red")
            resp = input(
                "Something went wrong with making the screenshots! Do you want to skip the post? (y/n) "
            )

            if resp.casefold().startswith("y"):
                save_data("", "", "skipped", reddit_id, "")
                print_substep(
                    "The post is successfully skipped! You can now restart the program and this post will skipped.",
                    "green",
                )

            resp = input("Do you want the error traceback for debugging purposes? (y/n)")
            if not resp.casefold().startswith("y"):
                exit()

            raise e

        if storymode:
            page.locator('[data-click-id="text"]').first.screenshot(
                path=f"assets/temp/{reddit_id}/png/story_content.png"
            )
        else:
            for idx, comment in enumerate(
                track(
                    reddit_object["comments"][:screenshot_num],
                    "Downloading screenshots...",
                )
            ):
                # Stop if we have reached the screenshot_num
                if idx >= screenshot_num:
                    break

                if page.locator('[data-testid="content-gate"]').is_visible():
                    page.locator('[data-testid="content-gate"] button').click()

                page.goto(f"https://new.reddit.com/{comment['comment_url']}")

                # translate code

                if settings.config["reddit"]["thread"]["post_lang"]:
                    comment_tl = translators.translate_text(
                        comment["comment_body"],
                        translator="google",
                        to_language=settings.config["reddit"]["thread"]["post_lang"],
                    )
                    page.evaluate(
                        '([tl_content, tl_id]) => document.querySelector(`#t1_${tl_id} > div:nth-child(2) > div > div[data-testid="comment"] > div`).textContent = tl_content',
                        [comment_tl, comment["comment_id"]],
                    )
                try:
                    if settings.config["settings"]["zoom"] != 1:
                        # store zoom settings
                        zoom = settings.config["settings"]["zoom"]
                        # zoom the body of the page
                        page.evaluate("document.body.style.zoom=" + str(zoom))
                        # scroll comment into view
                        page.locator(f"#t1_{comment['comment_id']}").scroll_into_view_if_needed()
                        # as zooming the body doesn't change the properties of the divs, we need to adjust for the zoom
                        location = page.locator(f"#t1_{comment['comment_id']}").bounding_box()
                        for i in location:
                            location[i] = float("{:.2f}".format(location[i] * zoom))
                        page.screenshot(
                            clip=location,
                            path=f"assets/temp/{reddit_id}/png/comment_{idx}.png",
                        )
                    else:
                        page.locator(f"#t1_{comment['comment_id']}").screenshot(
                            path=f"assets/temp/{reddit_id}/png/comment_{idx}.png"
                        )
                except TimeoutError:
                    del reddit_object["comments"]
                    screenshot_num += 1
                    print("TimeoutError: Skipping screenshot...")
                    continue

        # close browser instance when we are done using it
        browser.close()

    print_substep("Screenshots downloaded Successfully.", style="bold green")
-----------------------
voices.py

from typing import Tuple

from rich.console import Console

from TTS.aws_polly import AWSPolly
from TTS.elevenlabs import elevenlabs
from TTS.engine_wrapper import TTSEngine
from TTS.GTTS import GTTS
from TTS.pyttsx import pyttsx
from TTS.streamlabs_polly import StreamlabsPolly
from TTS.TikTok import TikTok
from utils import settings
from utils.console import print_step, print_table

console = Console()

TTSProviders = {
    "GoogleTranslate": GTTS,
    "AWSPolly": AWSPolly,
    "StreamlabsPolly": StreamlabsPolly,
    "TikTok": TikTok,
    "pyttsx": pyttsx,
    "ElevenLabs": elevenlabs,
}


def save_text_to_mp3(reddit_obj) -> Tuple[int, int]:
    """Saves text to MP3 files.

    Args:
        reddit_obj (): Reddit object received from reddit API in reddit/subreddit.py

    Returns:
        tuple[int,int]: (total length of the audio, the number of comments audio was generated for)
    """

    voice = settings.config["settings"]["tts"]["voice_choice"]
    if str(voice).casefold() in map(lambda _: _.casefold(), TTSProviders):
        text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, voice), reddit_obj)
    else:
        while True:
            print_step("Please choose one of the following TTS providers: ")
            print_table(TTSProviders)
            choice = input("\n")
            if choice.casefold() in map(lambda _: _.casefold(), TTSProviders):
                break
            print("Unknown Choice")
        text_to_mp3 = TTSEngine(get_case_insensitive_key_value(TTSProviders, choice), reddit_obj)
    return text_to_mp3.run()


def get_case_insensitive_key_value(input_dict, key):
    return next(
        (value for dict_key, value in input_dict.items() if dict_key.lower() == key.lower()),
        None,
    )
--------------------
.dockerignore
.gitattributes
.gitignore
.pylintrc
-----------------------
Automator.sh

#!/bin/bash

# Log file path
LOG_FILE=~/RedditVideoMakerBot-master/logs/script_log.txt

# Function to log both to stdout and to a file
log() {
    echo "$(date +'%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}

log "Script started."

# Sleep for a random amount of time between 2 minutes and 3 hours
 RANDOM_SLEEP=$((120 + RANDOM % 4800))  # Random time between 120 seconds (2 min) and 10800 seconds (1.4 hours)
 log "Sleeping for $RANDOM_SLEEP seconds."
 sleep $RANDOM_SLEEP


export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/usr/bin:/bin"
export WORKON_HOME="$HOME/.virtualenvs"
export VIRTUALENVWRAPPER_PYTHON="$(which python3)"

cd /home/user/RedditVideoMakerBot-master

# Activate the first virtual environment for RedditVideoMakerBot
log "Activating the virtual environment for RedditVideoMakerBot."
source ~/RedditVideoMakerBot-master/venvDigger/bin/activate


#Run ElevenLabsKeyRotator
python3 ~/RedditVideoMakerBot-master/elevenLabsKeyRotator.py 2>&1 | tee -a "$LOG_FILE"

sleep 60


# Run the Python script
log "Running main.py"
python3 ~/RedditVideoMakerBot-master/main.py 2>&1 | tee -a "$LOG_FILE"

# Sleep for 1 minute
log "Sleeping for 60 seconds."
sleep 60

# Find the latest created .mp4 file
log "Finding the latest .mp4 file in the results directory."
latest_file=$(ls -t ~/RedditVideoMakerBot-master/results/AmItheAsshole+relationship_advice+confession+tifu/*.mp4 | head -n 1)

if [[ -z "$latest_file" ]]; then
    log "No .mp4 files found! Exiting."
    deactivate  # Deactivate the virtual environment before exiting
    exit 1
else
    log "Found latest file: $latest_file"
fi

# Extract the file name without the .mp4 extension
filename_without_ext=$(basename "$latest_file" .mp4)
log "Extracted file name without extension: $filename_without_ext"

# Generate the word-by-word captions in the latest file with "_out"
log "Generating captions for $latest_file"
python3 ~/RedditVideoMakerBot-master/captionGen.py "$latest_file"  --font ~/RedditVideoMakerBot-master/fonts/Rubik-Black.ttf  2>&1 | tee -a "$LOG_FILE"

# Update the file path to add the "_out" at the end
latest_file="${latest_file%.mp4}_out.mp4"
log "Updated file name for the captioned version: $latest_file"

# Upload to YouTube
log "Uploading $latest_file to YouTube."
python3 ~/RedditVideoMakerBot-master/uploaders/youtubeUpload.py "$latest_file" "$filename_without_ext" 2>&1 | tee -a "$LOG_FILE"

# Upload to Instagram
log "Uploading $latest_file to Instagram."
python3 ~/RedditVideoMakerBot-master/uploaders/instaUpload.py "$latest_file" "$filename_without_ext" 2>&1 | tee -a "$LOG_FILE"

# Deactivate the first virtual environment
log "Deactivating RedditVideoMakerBot virtual environment."
deactivate

# Activate the second virtual environment for TikTok uploader
log "Activating the virtual environment for TikTok uploader."
source ~/RedditVideoMakerBot-master/uploaders/TiktokAutoUploader/.tokvenv/bin/activate

# Copy to TiktokUploaderSourceFolder
log "Copying $latest_file to TikTok uploader folder."
cp "$latest_file" ~/RedditVideoMakerBot-master/uploaders/TiktokAutoUploader/VideosDirPath

# Upload to TikTok
log "Uploading to TikTok using TikTok uploader."
cd ~/RedditVideoMakerBot-master/uploaders/TiktokAutoUploader/
python3 cli.py upload --user crazystorylord -v "${filename_without_ext}_out.mp4" -t "$filename_without_ext" 2>&1 | tee -a "$LOG_FILE"

# Deactivate the second virtual environment
log "Deactivating TikTok uploader virtual environment."
deactivate

log "Script completed."
---------------------------------
build.sh

#!/bin/sh
docker build -t rvmt .
--------------------------
captionGen.py

import os
import sys
import json
import wave
import urllib.request
import zipfile
import logging
import subprocess
from moviepy.editor import VideoFileClip, CompositeVideoClip, ImageClip
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from vosk import Model, KaldiRecognizer, SetLogLevel
import argparse


# Set up logging
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')

# Vosk model download function
# TODO small model can be used "vosk-model-small-en-us-0.15"
def download_vosk_model(model_name="vosk-model-en-us-0.22"):
    model_url = f"https://alphacephei.com/vosk/models/{model_name}.zip"
    model_path = os.path.join(os.path.dirname(__file__), model_name)
    
    if not os.path.exists(model_path):
        logging.info(f"Downloading Vosk model {model_name}...")
        zip_path, _ = urllib.request.urlretrieve(model_url)
        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            zip_ref.extractall(os.path.dirname(__file__))
        os.remove(zip_path)
        logging.info("Model downloaded and extracted.")
    return model_path

def extract_audio(video_path, audio_path):
    if os.path.exists(audio_path):
        logging.info(f"File '{audio_path}' already exists. Overwriting...")
        os.remove(audio_path)
	
    command = [
        "ffmpeg",
        "-i", video_path,
        "-acodec", "pcm_s16le",
        "-ac", "1",
        "-ar", "16000",
        audio_path
    ]
    subprocess.run(command, check=True)
    logging.info(f"Audio extracted to {audio_path}")

def transcribe_audio(audio_path, model_path):
    SetLogLevel(0)
    wf = wave.open(audio_path, "rb")
    if wf.getnchannels() != 1 or wf.getsampwidth() != 2 or wf.getcomptype() != "NONE":
        logging.error("Audio file must be WAV format mono PCM.")
        return []

    model = Model(model_path)
    rec = KaldiRecognizer(model, wf.getframerate())
    rec.SetWords(True)

    results = []
    while True:
        data = wf.readframes(4000)
        if len(data) == 0:
            break
        if rec.AcceptWaveform(data):
            part_result = json.loads(rec.Result())
            results.append(part_result)
    part_result = json.loads(rec.FinalResult())
    results.append(part_result)

    words = []
    for r in results:
        if 'result' in r:
            words.extend(r['result'])
    
    logging.info(f"Transcribed {len(words)} words")
    return words

def create_text_image(text, size, font_size, color, font_path, bg_color=(0, 0, 0, 0), border_size=15):
    # Increase the size of the image to accommodate the border
    increased_size = (size[0] + border_size * 2, size[1] + border_size * 2 + font_size // 2)
    img = Image.new('RGBA', increased_size, bg_color)
    draw = ImageDraw.Draw(img)
    
    # Load the main font
    font = ImageFont.truetype(font_path, font_size)
    
    # Calculate text position
    text_bbox = font.getbbox(text)
    text_width = text_bbox[2] - text_bbox[0]
    text_height = text_bbox[3] - text_bbox[1]
    position = ((increased_size[0] - text_width) // 2, (increased_size[1] - text_height) // 2)

    # Draw border
    for x_offset in range(-border_size, border_size + 1):
        for y_offset in range(-border_size, border_size + 1):
            draw.text((position[0] + x_offset, position[1] + y_offset), text, font=font, fill=(0, 0, 0, 255))  # Black border

    # Draw main text
    draw.text(position, text, font=font, fill=color)

    return np.array(img)




def create_caption_clips(word_timings, video_width, video_height, font_path):
    caption_clips = []
    font_size = 110  # Increased font size
    y_offset = 570  # Moving subtitles higher up on the screen

    for word in word_timings:
        img_array = create_text_image(word['word'], (video_width, 120), font_size, (255, 255, 255, 255), font_path)
        clip = ImageClip(img_array, duration=word['end'] - word['start'])
        
        # Adjusting position to move subtitles higher
        clip = clip.set_position(('center', video_height - y_offset)).set_start(word['start'])
        
        caption_clips.append(clip)
    
    logging.info(f"Created {len(caption_clips)} caption clips")
    return caption_clips



def main(input_video_path, output_video_path, font_path):
    # Download Vosk model if not present
    model_path = download_vosk_model()

    # Extract audio from video
    audio_path = "temp_audio.wav"
    extract_audio(input_video_path, audio_path)
    
    # Transcribe audio
    word_timings = transcribe_audio(audio_path, model_path)
    
    if not word_timings:
        logging.error("No words were transcribed. Check the audio quality and format.")
        return

    # Print first 10 transcribed words for debugging
    logging.info(f"First 10 transcribed words: {word_timings[:10]}")

    # Create caption clips
    video = VideoFileClip(input_video_path)
    caption_clips = create_caption_clips(word_timings, video.w, video.h, font_path)
    
    # Overlay captions on video
    final_video = CompositeVideoClip([video] + caption_clips)
    




    # Write output video
    final_video.write_videofile(output_video_path,)
    
    # Clean up temporary files
    os.remove(audio_path)
    logging.info("Video processing completed")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Add captions to video using Vosk and MoviePy.')
    parser.add_argument('input_video', type=str, help='Path to the input video file')
    parser.add_argument('--font', type=str, default='/home/user/RedditVideoMakerBot-master/fonts/Rubik-Black.ttf', help='Path to the font file')
    args = parser.parse_args()

    input_video = args.input_video
    output_video = os.path.splitext(input_video)[0] + "_out.mp4"
    font_path = args.font
    main(input_video, output_video, font_path)



-----------------------------
config.toml

[ai]
ai_similarity_enabled = false
ai_similarity_keywords = ""

[settings]
allow_nsfw = true
theme = "transparent"
times_to_run = 1
opacity = 0.9
storymode = true
storymodemethod = 1
storymode_max_length = 5000
resolution_w = 1080
resolution_h = 1920
zoom = 1.0
channel_name = ""

[settings.background]
background_video = "minecraft"
background_audio = "lofi"
background_audio_volume = 0.05
enable_extra_audio = false
background_thumbnail = true
background_thumbnail_font_family = "arial"
background_thumbnail_font_size = 96
background_thumbnail_font_color = "255,255,255"

[settings.tts]
voice_choice = "elevenlabs"
random_voice = false
elevenlabs_voice_name = "Brian"
elevenlabs_api_key = ""
aws_polly_voice = "Matthew"
streamlabs_polly_voice = "Matthew"
tiktok_voice = "en_us_001"
tiktok_sessionid = ""
python_voice = "1"
py_voice_num = "2"
silence_duration = 0.3
no_emojis = false

[reddit.creds]
client_id = ""
client_secret = ""
username = ""
password = ""
2fa = false

[reddit.thread]
random = true
subreddit = "AmItheAsshole+relationship_advice+confession+tifu"
post_id = ""
max_comment_length = 500
min_comment_length = 20
post_lang = ""
min_comments = 20
-----------------------------
Dockerfile

FROM python:3.10.14-slim

RUN apt update
RUN apt-get install -y ffmpeg
RUN apt install python3-pip -y

RUN mkdir /app
ADD . /app
WORKDIR /app
RUN pip install -r requirements.txt

CMD ["python3", "main.py"]
-------------------------------
elevenLabsKeyRotator.py

import toml
from datetime import datetime, timedelta
import json
import os
from pathlib import Path

class APIKeyRotator:
    def __init__(self, config_path='config.toml', key_storage_path='api_keys.json'):
        self.config_path = config_path
        self.key_storage_path = key_storage_path
        self.execution_count_path = 'execution_count.txt'
        self.api_keys = {
            'sk_yourElevenLabsKey': {'uses': 0, 'retired_date': None},
            'sk_otherElevenLabsKey': {'uses': 0, 'retired_date': None},
        }
        self.initialize_storage()

    def initialize_storage(self):
        # Initialize or load API keys storage
        if os.path.exists(self.key_storage_path):
            with open(self.key_storage_path, 'r') as f:
                self.api_keys = json.load(f)
        else:
            self.save_key_storage()

        # Initialize or load execution count
        if os.path.exists(self.execution_count_path):
            with open(self.execution_count_path, 'r') as f:
                self.execution_count = int(f.read().strip())
        else:
            self.execution_count = 0
            self.save_execution_count()

    def save_key_storage(self):
        with open(self.key_storage_path, 'w') as f:
            json.dump(self.api_keys, f, indent=4)

    def save_execution_count(self):
        with open(self.execution_count_path, 'w') as f:
            f.write(str(self.execution_count))

    def get_active_api_key(self):
        current_time = datetime.now()
        
        # Check for keys that can be reactivated
        for key, info in self.api_keys.items():
            if info['retired_date']:
                retired_date = datetime.fromisoformat(info['retired_date'])
                if current_time - retired_date >= timedelta(days=30):
                    info['retired_date'] = None
                    info['uses'] = 0

        # Find an available key
        for key, info in self.api_keys.items():
            if info['retired_date'] is None and info['uses'] < 10:
                return key

        raise Exception("No available API keys found!")

    def update_config(self, new_key):
        # Read the current config
        with open(self.config_path, 'r') as f:
            config = toml.load(f)

        # Update the API key
        config['settings']['tts']['elevenlabs_api_key'] = new_key

        # Write the updated config back
        with open(self.config_path, 'w') as f:
            toml.dump(config, f)

    def run(self):
        self.execution_count += 1
        current_key = None

        # Load current config to get the current key
        with open(self.config_path, 'r') as f:
            config = toml.load(f)
            current_key = config['settings']['tts']['elevenlabs_api_key']

        # Update the use count for the current key
        if current_key in self.api_keys:
            self.api_keys[current_key]['uses'] += 1

        # Check if we need to rotate the key (every 10th execution)
        if self.execution_count % 10 == 0:
            # Retire the current key if it exists and has been used 10 times
            if current_key in self.api_keys and self.api_keys[current_key]['uses'] >= 10:
                self.api_keys[current_key]['retired_date'] = datetime.now().isoformat()

            # Get a new key and update the config
            new_key = self.get_active_api_key()
            self.update_config(new_key)

        # Save the updated state
        self.save_key_storage()
        self.save_execution_count()

if __name__ == "__main__":
    rotator = APIKeyRotator()
    rotator.run()
-------------------------
GUI.py

import webbrowser
from pathlib import Path

# Used "tomlkit" instead of "toml" because it doesn't change formatting on "dump"
import tomlkit
from flask import (
    Flask,
    redirect,
    render_template,
    request,
    send_from_directory,
    url_for,
)

import utils.gui_utils as gui

# Set the hostname
HOST = "localhost"
# Set the port number
PORT = 4000

# Configure application
app = Flask(__name__, template_folder="GUI")

# Configure secret key only to use 'flash'
app.secret_key = b'_5#y2L"F4Q8z\n\xec]/'


# Ensure responses aren't cached
@app.after_request
def after_request(response):
    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    response.headers["Expires"] = 0
    response.headers["Pragma"] = "no-cache"
    return response


# Display index.html
@app.route("/")
def index():
    return render_template("index.html", file="videos.json")


@app.route("/backgrounds", methods=["GET"])
def backgrounds():
    return render_template("backgrounds.html", file="backgrounds.json")


@app.route("/background/add", methods=["POST"])
def background_add():
    # Get form values
    youtube_uri = request.form.get("youtube_uri").strip()
    filename = request.form.get("filename").strip()
    citation = request.form.get("citation").strip()
    position = request.form.get("position").strip()

    gui.add_background(youtube_uri, filename, citation, position)

    return redirect(url_for("backgrounds"))


@app.route("/background/delete", methods=["POST"])
def background_delete():
    key = request.form.get("background-key")
    gui.delete_background(key)

    return redirect(url_for("backgrounds"))


@app.route("/settings", methods=["GET", "POST"])
def settings():
    config_load = tomlkit.loads(Path("config.toml").read_text())
    config = gui.get_config(config_load)

    # Get checks for all values
    checks = gui.get_checks()

    if request.method == "POST":
        # Get data from form as dict
        data = request.form.to_dict()

        # Change settings
        config = gui.modify_settings(data, config_load, checks)

    return render_template("settings.html", file="config.toml", data=config, checks=checks)


# Make videos.json accessible
@app.route("/videos.json")
def videos_json():
    return send_from_directory("video_creation/data", "videos.json")


# Make backgrounds.json accessible
@app.route("/backgrounds.json")
def backgrounds_json():
    return send_from_directory("utils", "backgrounds.json")


# Make videos in results folder accessible
@app.route("/results/<path:name>")
def results(name):
    return send_from_directory("results", name, as_attachment=True)


# Make voices samples in voices folder accessible
@app.route("/voices/<path:name>")
def voices(name):
    return send_from_directory("GUI/voices", name, as_attachment=True)


# Run browser and start the app
if __name__ == "__main__":
    webbrowser.open(f"http://{HOST}:{PORT}", new=2)
    print("Website opened in new tab. Refresh if it didn't load.")
    app.run(port=PORT)
-----------------------
install.sh

#!/bin/bash 

# If the install fails, then print an error and exit.
function install_fail() {
    echo "Installation failed" 
    exit 1 
} 

# This is the help fuction. It helps users withe the options
function Help(){ 
    echo "Usage: install.sh [option]" 
    echo "Options:" 
    echo "  -h: Show this help message and exit" 
    echo "  -d: Install only dependencies" 
    echo "  -p: Install only python dependencies (including playwright)" 
    echo "  -b: Install just the bot"
    echo "  -l: Install the bot and the python dependencies"
} 

# Options
while getopts ":hydpbl" option; do
    case $option in
        # -h, prints help message
        h)
            Help exit 0;;
        # -y, assumes yes
        y)
            ASSUME_YES=1;;
        # -d install only dependencies
        d)
            DEPS_ONLY=1;;
        # -p install only python dependencies
        p)
            PYTHON_ONLY=1;;
        b)
            JUST_BOT=1;;
        l)
            BOT_AND_PYTHON=1;;
        # if a bad argument is given, then throw an error
        \?)
            echo "Invalid option: -$OPTARG" >&2 Help exit 1;;
        :)
            echo "Option -$OPTARG requires an argument." >&2 Help exit 1;;
    esac
done 

# Install dependencies for MacOS
function install_macos(){
    # Check if homebrew is installed
    if [ ! command -v brew &> /dev/null ]; then
        echo "Installing Homebrew"
        # if it's is not installed, then install it in a NONINTERACTIVE way
        NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" 
        # Check for what arcitecture, so you can place path.
        if [[ "uname -m" == "x86_64" ]]; then
            echo "export PATH=/usr/local/bin:$PATH" >> ~/.bash_profile && source ~/.bash_profile
        fi
    # If not
    else
        # Print that it's already installed
        echo "Homebrew is already installed"
    fi
    # Install the required packages
    echo "Installing required Packages" 
    if [! command --version python3 &> /dev/null ]; then
	    echo "Installing python3"
	    brew install python@3.10
    else
	    echo "python3 already installed."
    fi
    brew install tcl-tk python-tk
} 

# Function to install for arch (and other forks like manjaro)
function install_arch(){ 
    echo "Installing required packages"
    sudo pacman -S --needed python3 tk git && python3 -m ensurepip unzip || install_fail
} 

# Function to install for debian (and ubuntu)
function install_deb(){ 
    echo "Installing required packages"
    sudo apt install python3 python3-dev python3-tk python3-pip unzip || install_fail
} 

# Function to install for fedora (and other forks)
function install_fedora(){ 
    echo "Installing required packages" 
    sudo dnf install python3 python3-tkinter python3-pip python3-devel unzip || install_fail
} 

# Function to install for centos (and other forks based on it)
function install_centos(){
    echo "Installing required packages"
    sudo yum install -y python3 || install_fail
    sudo yum install -y python3-tkinter epel-release python3-pip unzip|| install_fail
} 

function get_the_bot(){ 
    echo "Downloading the bot" 
    rm -rf RedditVideoMakerBot-master
    curl -sL https://github.com/elebumm/RedditVideoMakerBot/archive/refs/heads/master.zip -o master.zip
    unzip master.zip
    rm -rf master.zip
} 

#install python dependencies
function install_python_dep(){ 
    # tell the user that the script is going to install the python dependencies
    echo "Installing python dependencies" 
    # cd into the directory
    cd RedditVideoMakerBot-master
    # install the dependencies
    pip3 install -r requirements.txt 
    # cd out
    cd ..
} 

# install playwright function
function install_playwright(){
    # tell the user that the script is going to install playwright 
    echo "Installing playwright"
    # cd into the directory where the script is downloaded
    cd RedditVideoMakerBot-master
    # run the install script
    python3 -m playwright install 
    python3 -m playwright install-deps 
    # give a note
    printf "Note, if these gave any errors, playwright may not be officially supported on your OS, check this issues page for support\nhttps://github.com/microsoft/playwright/issues"
    if [ -x "$(command -v pacman)" ]; then
        printf "It seems you are on and Arch based distro.\nTry installing these from the AUR for playwright to run:\nenchant1.6\nicu66\nlibwebp052\n"
    fi
    cd ..
} 

# Install depndencies
function install_deps(){ 
    # if the platform is mac, install macos
    if [ "$(uname)" == "Darwin" ]; then
        install_macos || install_fail
    # if pacman is found
    elif [ -x "$(command -v pacman)" ]; then
        # install for arch
        install_arch || install_fail
    # if apt-get is found
    elif [ -x "$(command -v apt-get)" ]; then
        # install fro debian
        install_deb || install_fail
    # if dnf is found
    elif [ -x "$(command -v dnf)" ]; then
        # install for fedora
        install_fedora || install_fail
    # if yum is found
    elif [ -x "$(command -v yum)" ]; then
        # install for centos
        install_centos || install_fail
    # else
    else
        # print an error message and exit
        printf "Your OS is not supported\n Please install python3, pip3 and git manually\n After that, run the script again with the -pb option to install python and playwright dependencies\n If you want to add support for your OS, please open a pull request on github\n
https://github.com/elebumm/RedditVideoMakerBot"
        exit 1
    fi
}

# Main function
function install_main(){ 
    # Print that are installing
    echo "Installing..." 
    # if -y (assume yes) continue 
    if [[ ASSUME_YES -eq 1 ]]; then
        echo "Assuming yes"
    # else, ask if they want to continue
    else
        echo "Continue? (y/n)" 
        read answer 
        # if the answer is not yes, then exit
        if [ "$answer" != "y" ]; then
            echo "Aborting" 
            exit 1
        fi
    fi 
    # if the -d (only dependencies) options is selected install just the dependencies
    if [[ DEPS_ONLY -eq 1 ]]; then
        echo "Installing only dependencies" 
        install_deps
    elif [[ PYTHON_ONLY -eq 1 ]]; then
    # if the -p (only python dependencies) options is selected install just the python dependencies and playwright
        echo "Installing only python dependencies" 
        install_python_dep 
        install_playwright
    # if the -b (only the bot) options is selected install just the bot
    elif [[ JUST_BOT -eq 1 ]]; then
        echo "Installing only the bot"
        get_the_bot
    # if the -l (bot and python) options is selected install just the bot and python dependencies
    elif [[ BOT_AND_PYTHON -eq 1 ]]; then
        echo "Installing only the bot and python dependencies"
        get_the_bot
        install_python_dep
    # else, install everything
    else
        echo "Installing all" 
        install_deps 
        get_the_bot 
        install_python_dep
        install_playwright
    fi

    DIR="./RedditVideoMakerBot-master"
    if [ -d "$DIR" ]; then
        printf "\nThe bot is installed, want to run it?"
        # if -y (assume yes) continue 
        if [[ ASSUME_YES -eq 1 ]]; then
            echo "Assuming yes"
            # else, ask if they want to continue
        else
            echo "Continue? (y/n)" 
            read answer 
            # if the answer is not yes, then exit
            if [ "$answer" != "y" ]; then
                echo "Aborting" 
                exit 1
            fi
        fi
        cd RedditVideoMakerBot-master
        python3 main.py
    fi
}

# Run the main function
install_main
--------------------------
main.py

#!/usr/bin/env python
import math
import sys
from os import name
from pathlib import Path
from subprocess import Popen
from typing import NoReturn

from prawcore import ResponseException

from reddit.subreddit import get_subreddit_threads
from utils import settings
from utils.cleanup import cleanup
from utils.console import print_markdown, print_step, print_substep
from utils.ffmpeg_install import ffmpeg_install
from utils.id import id
from utils.version import checkversion
from video_creation.background import (
    chop_background,
    download_background_audio,
    download_background_video,
    get_background_config,
)
from video_creation.final_video import make_final_video
from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts
from video_creation.voices import save_text_to_mp3

__VERSION__ = "3.3.0"

print(
    """
██████╗ ███████╗██████╗ ██████╗ ██╗████████╗    ██╗   ██╗██╗██████╗ ███████╗ ██████╗     ███╗   ███╗ █████╗ ██╗  ██╗███████╗██████╗
██╔══██╗██╔════╝██╔══██╗██╔══██╗██║╚══██╔══╝    ██║   ██║██║██╔══██╗██╔════╝██╔═══██╗    ████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗
██████╔╝█████╗  ██║  ██║██║  ██║██║   ██║       ██║   ██║██║██║  ██║█████╗  ██║   ██║    ██╔████╔██║███████║█████╔╝ █████╗  ██████╔╝
██╔══██╗██╔══╝  ██║  ██║██║  ██║██║   ██║       ╚██╗ ██╔╝██║██║  ██║██╔══╝  ██║   ██║    ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝  ██╔══██╗
██║  ██║███████╗██████╔╝██████╔╝██║   ██║        ╚████╔╝ ██║██████╔╝███████╗╚██████╔╝    ██║ ╚═╝ ██║██║  ██║██║  ██╗███████╗██║  ██║
╚═╝  ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚═╝   ╚═╝         ╚═══╝  ╚═╝╚═════╝ ╚══════╝ ╚═════╝     ╚═╝     ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝
"""
)
print_markdown(
    "### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/"
)
checkversion(__VERSION__)


def main(POST_ID=None) -> None:
    global redditid, reddit_object
    reddit_object = get_subreddit_threads(POST_ID)
    redditid = id(reddit_object)
    length, number_of_comments = save_text_to_mp3(reddit_object)
    length = math.ceil(length)
    get_screenshots_of_reddit_posts(reddit_object, number_of_comments)
    bg_config = {
        "video": get_background_config("video"),
        "audio": get_background_config("audio"),
    }
    download_background_video(bg_config["video"])
    download_background_audio(bg_config["audio"])
    chop_background(bg_config, length, reddit_object)
    make_final_video(number_of_comments, length, reddit_object, bg_config)


def run_many(times) -> None:
    for x in range(1, times + 1):
        print_step(
            f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}'
        )  # correct 1st 2nd 3rd 4th 5th....
        main()
        Popen("cls" if name == "nt" else "clear", shell=True).wait()


def shutdown() -> NoReturn:
    if "redditid" in globals():
        print_markdown("## Clearing temp files")
        cleanup(redditid)

    print("Exiting...")
    sys.exit()


if __name__ == "__main__":
    if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]:
        print(
            "Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again."
        )
        sys.exit()
    ffmpeg_install()
    directory = Path().absolute()
    config = settings.check_toml(
        f"{directory}/utils/.config.template.toml", f"{directory}/config.toml"
    )
    config is False and sys.exit()

    if (
        not settings.config["settings"]["tts"]["tiktok_sessionid"]
        or settings.config["settings"]["tts"]["tiktok_sessionid"] == ""
    ) and config["settings"]["tts"]["voice_choice"] == "tiktok":
        print_substep(
            "TikTok voice requires a sessionid! Check our documentation on how to obtain one.",
            "bold red",
        )
        sys.exit()
    try:
        if config["reddit"]["thread"]["post_id"]:
            for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")):
                index += 1
                print_step(
                    f'on the {index}{("st" if index % 10 == 1 else ("nd" if index % 10 == 2 else ("rd" if index % 10 == 3 else "th")))} post of {len(config["reddit"]["thread"]["post_id"].split("+"))}'
                )
                main(post_id)
                Popen("cls" if name == "nt" else "clear", shell=True).wait()
        elif config["settings"]["times_to_run"]:
            run_many(config["settings"]["times_to_run"])
        else:
            main()
    except KeyboardInterrupt:
        shutdown()
    except ResponseException:
        print_markdown("## Invalid credentials")
        print_markdown("Please check your credentials in the config.toml file")
        shutdown()
    except Exception as err:
        config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED"
        config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED"
        print_step(
            f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n"
            f"Version: {__VERSION__} \n"
            f"Error: {err} \n"
            f'Config: {config["settings"]}'
        )
        raise err
-----------------------
ptt.py

import pyttsx3

engine = pyttsx3.init()
voices = engine.getProperty("voices")
for voice in voices:
    print(voice, voice.id)
    engine.setProperty("voice", voice.id)
    engine.say("Hello World!")
    engine.runAndWait()
    engine.stop()
---------------------
README.md

# Fully Automated Reddit Stories Video Generator & Poster to TikTok, Instagram Reels, and YouTube Shorts

## Introduction
This repository contains a fully automated system that generates and posts Reddit stories to three major platforms: TikTok, Instagram Reels, and YouTube Shorts.

### Why Use This?
If you're looking into this, you likely know that 1 million views on TikTok can earn around €20. This tool will automatically produce brain-rotting videos with **zero effort** after the initial setup. With multiple accounts, you can scale this infinitely and potentially make a decent profit.

The base of this project is a heavily modified version of [RedditVideoMakerBot](https://github.com/elebumm/RedditVideoMakerBot). I’ve extended it with features such as:
- Generating **dyslexic-style** one-word captions that grab and retain user attention (simular to the CapCut style subtitles).
- Automated uploaders for TikTok, Instagram Reels, and YouTube Shorts (a highly requested feature not available in the original bot due to ethical/legal concerns).
-  Something extra in there for you, the exta cheap one: **ElevenLabs free tier API key rotator** .


After setup, you can let this brain-rot farm automatically generate passive income.
Sample Results: [MyTikTokProfile](https://www.tiktok.com/@crazystorylord)


## Features
- **Reddit Stories**: Automatically sourced and turned into videos.
- **One-word Captions**: Attention-grabbing text overlays.
- **Uploaders to All platforms**: Fully automated uploads to TikTok, Instagram Reels, and YouTube Shorts.
- **TTS**: ElevenLabs API integration with key rotation, or free unlimited usage with StreamLabs Polly.
- **Modular Config**: with this repo you get my configuration, but feel free to Adjust everything via `config.toml`, you can even delete config.toml and call main.py to generate a brand new config from scratch through the wizzard.

## Installation

### Supported Python Versions
- Python 3.10 or 3.12

### Steps:
1. Set up a virtual environment:
    ```bash
    python3.12 -m venv venvDigger
    source venvDigger/bin/activate
    ```   
naming matter for the Automator.sh script 

2. Install the required dependencies:
    ```bash
    pip install -r requirements.txt
    pip install pydantic==2.7.4     # to fix  dependency mismatch 
    ```


3. Deactivate the virtual environment:
    ```bash
    deactivate
    ```

4. Set up TikTok uploader:
    ```bash
    cd uploaders/TiktokAutoUploader
    python3.12 -m venv .tokvenv
    source .tokvenv/bin/activate
    pip install -r requirements.txt
    ```

    ```bash
    cd uploaders/TiktokAutoUploader/tiktok_uploader/tiktok-signature/
    npm i
    npx playwright install
    ```

### Background Video Setup
Download an undetectable "unoriginal content" Minecraft or other background video with the following command:
```bash
yt-dlp -f 'bestvideo[height=1080]+bestaudio/best' -ciw -o "%(title)s.%(ext)s" <video_url>
```
Place the video inside `assets/backgrounds/video/` and rename it to `bbswitzer-parkour.mp4`.

### Text-to-Speech (TTS)
If you want the best sounding TTS, you will need to use ElevenLabs. The free tier allows for about 5 videos per month, or you can use multiple free-tier API keys with the provided **ElevenLabsKeyRotator** script  (they only want a fresh email adress for you to generate a key :) .

if you want to go the elevenlabs route modify `elevenLabsKeyRotator.py` and put at least 15 api keys in there  (depends on how often you want to upload)

if you dont want to use elevenLabs modify `Automathor.sh` and coment out the execution of `elevenLabsKeyRotator.py`
 - Alternatively, you can use **StreamLabs Polly**, which offers free, unlimited usage  (read next steps).
 - or you can pay for elevenlabs


### Configuration Setup
Generate or modify the `config.toml`:

1. Run the following command to launch the CLI UI and finish your configuration:
     ```bash
    python3 main.py
    ```
- Alternatively, modify the `config.toml` directly. Here’s what you’ll need:
  
   #### **DISCLAIMER**: changing the subreddits will result in a diffrent output folder name , modify the Automator.sh to reflect that  
   - **TTS Engine**: Choose between ElevenLabs or StreamLabs Polly, (if you chose elevenlabs provide one of your keys).
   - **Reddit Credentials**: Visit [Reddit Apps](https://www.reddit.com/prefs/apps) to create your app. Detailed instructions can be found [here](https://reddit-video-maker-bot.netlify.app/docs/configuring).
   - 

## Logins
logins shoud be executed in an enviorment that suports gui to open up a browser, you can run all scripts on some other machine and then transfer the token files to your server (keep in mind that you also need to setup the proper enviorment and pakages) 

### TikTok
1. Navigate to `uploaders/TiktokAutoUploader` and activate the TikTok virtual environment:
    ```bash
    source .tokvenv/bin/activate
    ```
2. Log in:
    ```bash
    python3 cli.py login -n my_tiktok_username
    ```

 If you’re running this on another machine, copy the `CookiesDir` to your server machine.

### Instagram
1. Modify `uploaders/instagram_creds.conf` with your credentials.
2. Upload a test video using the following command:
    ```bash
    python3 uploaders/instaUpload.py "path_to_video" "description"
    ```


**Notes**:
- make sure that your loging in from a know to instagram for you ip address
- the cli migh ask you for an sms code or to enter your password again
- you cant use 2FA. You will need to disable it, if your having problems loging in refer to [instagrapi documentation](https://subzeroid.github.io/instagrapi/).

### YouTube
1. Sign up for YouTube API v3 on Google Cloud Console (free: you can upload one video every 24 hours).
2. Place your `youtube_client_secret.json` in the `uploaders/` folder.
3. Test the setup:
    ```bash
    python3 uploaders/youtubeUpload.py "path_to_video" "description"
    ```
your default browser will open and you will be prompted to log in and authorize access to your YouTube account. Afterward, a `YTtoken.json` file will be generated.
 If you’re running this on another machine, copy the `YTtoken.json` to your server machine.

### Vosk for Captions
If you don’t have 9GB of RAM or swap space, you can switch to Vosk 0.15 small. However, the captions might not be as accurate. To switch models, modify `captionsGen.py` to use `vosk-model-small-en-us-0.15`.

## Running the Full Flow
Once you've tested each component, run the full flow with:
```bash
./RunFullFlowInstant.sh
```

To automate it, create a cron job:
```bash
0 */7 * * * su user -c "/home/user/RedditVideoMakerBot-master/Automator.sh"
```
I recommend setting it to every 7 hours. The `Automator.sh` script also includes a built-in random delay between 2 minutes and 3 hours.

## What next
You can scale up this whole operation and setup multiple enviorments with diffrent accounts, just set them up in a non-interfiaring schedule  

----------------------------
requirements.txt

boto3==1.34.127
botocore==1.34.127
gTTS==2.5.1
moviepy==1.0.3
playwright==1.44.0
praw==7.7.1
prawcore~=2.3.0
requests==2.32.3
rich==13.7.1
toml==0.10.2
translators==5.9.2
pyttsx3==2.90
Pillow==10.3.0
tomlkit==0.12.5
Flask==3.0.3
clean-text==0.6.0
unidecode==1.3.8
spacy==3.7.5
torch==2.3.1
transformers==4.41.2
ffmpeg-python==0.2.0
elevenlabs==1.3.0
yt-dlp==2024.5.27
numpy==1.26.4
vosk 
instagrapi
google-auth
google-auth-oauthlib
google-auth-httplib2
google-api-python-client
pydantic==2.7.4
------------------------------
Rubik-Black.ttf
---------------------
run.bat

@echo off
set VENV_DIR=.venv

if exist "%VENV_DIR%" (
    echo Activating virtual environment...
    call "%VENV_DIR%\Scripts\activate.bat"
)

echo Running Python script...
python main.py

if errorlevel 1 (
    echo An error occurred. Press any key to exit.
    pause >nul
)
--------------------------
run.sh

#!/bin/sh
docker run -v $(pwd)/out/:/app/assets -v $(pwd)/.env:/app/.env -it rvmt
----------------------
RunFullFlowInstant.sh

#!/bin/bash

# Log file path
LOG_FILE=~/RedditVideoMakerBot-master/logs/script_log.txt

# Function to log both to stdout and to a file
log() {
    echo "$(date +'%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}

log "Script started."

# Sleep for a random amount of time between 2 minutes and 3 hours
# RANDOM_SLEEP=$((120 + RANDOM % 4800))  # Random time between 120 seconds (2 min) and 10800 seconds (1.4 hours)
# log "Sleeping for $RANDOM_SLEEP seconds."
# sleep $RANDOM_SLEEP


export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/usr/bin:/bin"
export WORKON_HOME="$HOME/.virtualenvs"
export VIRTUALENVWRAPPER_PYTHON="$(which python3)"


# Activate the first virtual environment for RedditVideoMakerBot
log "Activating the virtual environment for RedditVideoMakerBot."
source ~/RedditVideoMakerBot-master/venvDigger/bin/activate

# Run the Python script
log "Running main.py"
python3 ~/RedditVideoMakerBot-master/main.py 2>&1 | tee -a "$LOG_FILE"

# Sleep for 1 minute
log "Sleeping for 60 seconds."
sleep 60

# Find the latest created .mp4 file
log "Finding the latest .mp4 file in the results directory."
latest_file=$(ls -t ~/RedditVideoMakerBot-master/results/AmItheAsshole+relationship_advice+confession+tifu/*.mp4 | head -n 1)

if [[ -z "$latest_file" ]]; then
    log "No .mp4 files found! Exiting."
    deactivate  # Deactivate the virtual environment before exiting
    exit 1
else
    log "Found latest file: $latest_file"
fi

# Extract the file name without the .mp4 extension
filename_without_ext=$(basename "$latest_file" .mp4)
log "Extracted file name without extension: $filename_without_ext"

# Generate the word-by-word captions in the latest file with "_out"
log "Generating captions for $latest_file"
python3 ~/RedditVideoMakerBot-master/captionGen.py "$latest_file"  --font ~/RedditVideoMakerBot-master/fonts/Rubik-Black.ttf  2>&1 | tee -a "$LOG_FILE"

# Update the file path to add the "_out" at the end
latest_file="${latest_file%.mp4}_out.mp4"
log "Updated file name for the captioned version: $latest_file"

# Upload to YouTube
log "Uploading $latest_file to YouTube."
python3 ~/RedditVideoMakerBot-master/uploaders/youtubeUpload.py "$latest_file" "$filename_without_ext" 2>&1 | tee -a "$LOG_FILE"

# Upload to Instagram
log "Uploading $latest_file to Instagram."
python3 ~/RedditVideoMakerBot-master/uploaders/instaUpload.py "$latest_file" "$filename_without_ext" 2>&1 | tee -a "$LOG_FILE"

# Deactivate the first virtual environment
log "Deactivating RedditVideoMakerBot virtual environment."
deactivate

# Activate the second virtual environment for TikTok uploader
log "Activating the virtual environment for TikTok uploader."
source ~/RedditVideoMakerBot-master/uploaders/TiktokAutoUploader/.tokvenv/bin/activate

# Copy to TiktokUploaderSourceFolder
log "Copying $latest_file to TikTok uploader folder."
cp "$latest_file" ~/RedditVideoMakerBot-master/uploaders/TiktokAutoUploader/VideosDirPath

# Upload to TikTok
log "Uploading to TikTok using TikTok uploader."
cd ~/RedditVideoMakerBot-master/uploaders/TiktokAutoUploader/
python3 cli.py upload --user crazystorylord -v "${filename_without_ext}_out.mp4" -t "$filename_without_ext" 2>&1 | tee -a "$LOG_FILE"

# Deactivate the second virtual environment
log "Deactivating TikTok uploader virtual environment."
deactivate

log "Script completed."