├── .github └── demo.gif ├── .gitignore ├── .postcssrc ├── README.md ├── frontend ├── index.html └── src │ ├── css │ ├── errors.css │ ├── loading-animation.css │ └── style.css │ ├── fonts │ ├── Gontserrat-Light.ttf │ └── Gontserrat-Regular.ttf │ ├── images │ ├── close.svg │ ├── favicon.png │ └── scale-screen.svg │ └── js │ └── app.js ├── package-lock.json ├── package.json ├── requirements.txt └── server ├── get_videos.py └── main.py /.github/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SrikarKSV/MultiTube/94f09815203d4155a84a59259cad49ea83610b94/.github/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .vscode 3 | __pycache__ 4 | node_modules 5 | dist 6 | .cache 7 | .parcel-cache 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /.postcssrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "autoprefixer": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultiTube - No more extra tabs 2 | 3 | MultiTube is a web app made to *watch all the YoutTube videos of a playlist/channel or a mix of both on the same page*🤯. 4 | 5 | Check it out here: [MultiTube](https://getmultitube.netlify.app/) 6 | 7 | ![Demo of MultiTube](./.github/demo.gif) 8 | 9 | You can provide a list of video links, playlist links or channel links or mix of all of them! With a click of a few buttons all of the videos from that playlist or channel can be viewed on same page, no more opening 10 tabs to keep a list of all the videos you want to watch in an evening. 10 | 11 | ## Features 12 | 13 | You can provide it: 14 | 15 | - Multiple video links 16 | - Multiple playlist links 17 | - Multiple channel links 18 | - Any mix of the above 19 | - Links are validated upon submission 20 | - Invalid links are alerted to the user 21 | - Users will be alerted if there was an error at the server 22 | - Users will be alerted if duplicate links are provided 23 | - Remove any video you don't want 24 | - Scale any video for better view 25 | 26 | > **_NOTE_** ⚠: When providing multiple links, between each link a comma(",") should be present. 27 | 28 |
29 | 30 | ## Usage: 31 | 32 | 1. Multiple video links:
33 | `https://www.youtube.com/watch?v=mO_dS3rXDIs, www.youtube.com/watch?v=48NWaLkDcME` 34 | 35 | 2. Multiple playlist links:
36 | `youtube.com/playlist?list=PLu8EoSxDXHP6CGK4YVJhL_VWetA865GOH, https://youtube.com/playlist?list=PLu8EoSxDXHP7v7K5nZSMo9XWidbJ_Bns3` 37 | 38 | 3. Multiple channel links:
39 | `youtube.com/user/wesbos, www.youtube.com/channel/UC29ju8bIPH5as8OGnQzwJyA` 40 | 41 | 4. Go all out, mix them:
42 | `https://youtube.com/watch?v=8rD9amRSOQY, youtube.com/playlist?list=PLu8EoSxDXHP5CIFvt9-ze3IngcdAc2xKG, www.youtube.com/channel/UCCezIgC97PvUuR4_gbFUs5g` 43 | 44 | > **_NOTE_** ⚠: Please do not enter custom channel URL, Ex: https://www.youtube.com/c/Coreyms/ . Youtube API does not respond these links. 45 | 46 | ### Stack used: 47 | 48 | - HTML 49 | - CSS 50 | - JavaScript 51 | - Flask (Python) 52 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | MultiTube — All the videos on the same page 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 31 |
32 |

Enter Video Links

33 |
34 | 36 | 42 |
43 | 44 |
45 |
46 |
47 |
48 | 54 |
55 |
56 |
57 | 58 | 62 | 66 | 67 |
68 |
69 |
70 |
71 | 72 |

There was an error at our servers. Please comeback later.

73 |
74 |
75 |
76 |
77 | 78 |

You can enter YouTube links of:

79 |
    80 |
  • Individual videos
  • 81 |
  • Playlist
  • 82 |
  • Channel
  • 83 |
  • Or you can enter comma-separated links of any of the above
  • 84 | 87 |
88 | 89 |
    90 |
  • youtube.com/channel/*channel_id*
  • 91 |
  • youtube.com/user/*user_name*
  • 92 |
93 | 95 |
96 |
97 | 108 |
109 | 110 | 111 | 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /frontend/src/css/errors.css: -------------------------------------------------------------------------------- 1 | .error-404-container, 2 | .error-403-container, 3 | .info { 4 | position: fixed; 5 | top: 2rem; 6 | left: 0; 7 | width: 100%; 8 | transform: translateY(-200%); 9 | transition: transform 0.5s; 10 | } 11 | 12 | .show-info { 13 | cursor: pointer; 14 | position: fixed; 15 | top: 5px; 16 | right: 5px; 17 | background-color: transparent; 18 | border: none; 19 | font-size: 1.5rem; 20 | color: var(--button-bg); 21 | } 22 | 23 | .info a { 24 | color: #fff; 25 | } 26 | 27 | .error-404 { 28 | position: relative; 29 | width: 65%; 30 | max-height: 85vh; 31 | margin: auto; 32 | padding: 3em 2em 0.6em; 33 | background-color: #214252; 34 | color: white; 35 | font-family: var(--font-light); 36 | border-radius: var(--button-border-radius); 37 | overflow: auto; 38 | overflow-wrap: break-word; 39 | } 40 | 41 | .error-404 p { 42 | font-size: 1.3rem; 43 | margin-bottom: 1rem; 44 | } 45 | 46 | .error-404 .continue-next-link { 47 | margin-top: 1rem; 48 | font-size: 1rem; 49 | } 50 | 51 | .error-404-container.open, 52 | .error-403-container.open, 53 | .info.open { 54 | transform: translateY(1%); 55 | } 56 | 57 | .invalid-link, 58 | .duplicate-link { 59 | padding: 0 1.3em; 60 | } 61 | 62 | .invalid-link > li, 63 | .duplicate-link > li { 64 | line-height: 1.5rem; 65 | } 66 | 67 | .duplicate-links { 68 | margin-top: 0.5rem; 69 | } 70 | 71 | .close { 72 | position: absolute; 73 | top: 1rem; 74 | right: 1rem; 75 | border: 0; 76 | background-color: #ff4b5c; 77 | color: white; 78 | padding: 0.2em 0.5em; 79 | border-radius: calc(var(--button-border-radius) - 4px); 80 | cursor: pointer; 81 | font-size: 1rem; 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/css/loading-animation.css: -------------------------------------------------------------------------------- 1 | .lds-ripple { 2 | --size: 20px; 3 | display: inline-block; 4 | position: relative; 5 | 6 | width: var(--size); 7 | height: var(--size); 8 | } 9 | 10 | .lds-ripple div { 11 | position: absolute; 12 | border: 4px solid #ff4b5c; 13 | opacity: 1; 14 | border-radius: 50%; 15 | animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite; 16 | } 17 | 18 | .lds-ripple div:nth-child(2) { 19 | animation-delay: -0.5s; 20 | } 21 | 22 | @keyframes lds-ripple { 23 | 0% { 24 | top: calc(var(--size) / 2); 25 | left: calc(var(--size) / 2); 26 | width: 0; 27 | height: 0; 28 | opacity: 1; 29 | } 30 | 31 | 100% { 32 | top: 0px; 33 | left: 0px; 34 | width: calc(var(--size) * 0.9); 35 | height: calc(var(--size) * 0.9); 36 | opacity: 0; 37 | } 38 | } 39 | 40 | .hidden { 41 | display: none; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/css/style.css: -------------------------------------------------------------------------------- 1 | @import "./errors.css"; 2 | @import "./loading-animation.css"; 3 | 4 | * { 5 | margin: 0; 6 | padding: 0; 7 | box-sizing: border-box; 8 | } 9 | 10 | @font-face { 11 | font-family: "Gontserrat Light"; 12 | src: url(../fonts/Gontserrat-Light.ttf); 13 | } 14 | 15 | @font-face { 16 | font-family: "Gontserrat Regular"; 17 | src: url(../fonts/Gontserrat-Regular.ttf); 18 | } 19 | 20 | :root { 21 | --font-size-header: 3rem; 22 | --font-size-body: 1.2rem; 23 | --font-regular: "Gontserrat Regular", sans-serif; 24 | --font-light: "Gontserrat Light", sans-serif; 25 | --text-field-width: 40rem; 26 | --button-bg: #fbf7f0; 27 | --button-border-radius: 8px; 28 | --box-shadow-color: #555555; 29 | } 30 | 31 | body { 32 | background: #d9e4dd; 33 | } 34 | /******************************* Header *******************************/ 35 | header { 36 | background-color: #ff4b5c; 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | height: 20rem; 41 | } 42 | 43 | .github-code { 44 | position: absolute; 45 | top: -87px; 46 | left: -40px; 47 | font-size: 2rem; 48 | background-color: black; 49 | padding-top: 6rem; 50 | transform: rotate(-44deg); 51 | border-radius: 8px; 52 | transition: transform 0.3s; 53 | } 54 | 55 | .github-code:hover { 56 | transform: rotate(-44deg) translateY(1rem); 57 | } 58 | 59 | .github-code a { 60 | padding: 0rem; 61 | height: 100%; 62 | margin-left: -0.5rem; 63 | } 64 | 65 | h1 { 66 | text-align: center; 67 | color: white; 68 | font-size: var(--font-size-header); 69 | margin: 2.5rem 0 3rem; 70 | font-family: var(--font-regular); 71 | text-shadow: 2px 3px rgba(0, 0, 0, 0.5); 72 | } 73 | 74 | .link-field { 75 | padding: 0.3em; 76 | width: var(--text-field-width); 77 | font-size: var(--font-size-body); 78 | font-family: var(--font-light); 79 | } 80 | 81 | .get-video-btn { 82 | display: block; 83 | border: 0; 84 | cursor: pointer; 85 | margin: 2.8rem auto 0; 86 | padding: 0.5em; 87 | background-color: var(--button-bg); 88 | border-radius: var(--button-border-radius); 89 | font-size: var(--font-size-body); 90 | font-family: var(--font-regular); 91 | } 92 | 93 | /******************************* Main *******************************/ 94 | .main-container { 95 | min-height: calc(100vh - 20rem); 96 | display: flex; 97 | flex-direction: column; 98 | justify-content: space-between; 99 | } 100 | 101 | main { 102 | padding: 1px; 103 | } 104 | 105 | .videos { 106 | width: 95%; 107 | margin: 5rem auto; 108 | display: grid; 109 | grid-template-columns: repeat(auto-fill, minmax(500px, 1fr)); 110 | gap: 20px; 111 | row-gap: 60px; 112 | } 113 | 114 | .videoWrapper { 115 | position: relative; 116 | padding-bottom: 56.25%; 117 | height: 0; 118 | transition: all 0.3s; 119 | } 120 | 121 | .videoWrapper.scale-video { 122 | margin: 3rem auto; 123 | padding-bottom: 45%; 124 | width: 80vw; 125 | } 126 | 127 | .videoWrapper.remove-video { 128 | transform: scale(0.3); 129 | opacity: 0; 130 | } 131 | 132 | .videoWrapper iframe { 133 | position: absolute; 134 | top: 0; 135 | left: 0; 136 | width: 100%; 137 | height: 100%; 138 | border: 1px solid black; 139 | } 140 | 141 | .videoWrapper p { 142 | font-family: var(--font-light); 143 | position: absolute; 144 | top: 0%; 145 | left: 0%; 146 | width: 100%; 147 | height: 100%; 148 | display: flex; 149 | justify-content: center; 150 | align-items: center; 151 | font-size: 2rem; 152 | } 153 | 154 | .more { 155 | display: block; 156 | margin: 0 auto 3rem; 157 | border-radius: var(--button-border-radius); 158 | font-family: var(--font-regular); 159 | background-color: var(--button-bg); 160 | font-size: 1.5rem; 161 | padding: 0.3em 1em; 162 | cursor: pointer; 163 | border: 3px solid #555555; 164 | transition: box-shadow 0.3s; 165 | display: none; 166 | } 167 | 168 | .more:hover { 169 | box-shadow: 2.5px 2.2px 1px var(--box-shadow-color); 170 | } 171 | 172 | /******************************* Video Controls *******************************/ 173 | .remove, 174 | .scale { 175 | display: block; 176 | width: 1.3rem; 177 | height: 1.3rem; 178 | border: none; 179 | position: absolute; 180 | cursor: pointer; 181 | } 182 | 183 | .remove { 184 | background: url(../images/close.svg); 185 | top: -1.5rem; 186 | right: 0; 187 | } 188 | 189 | .scale { 190 | background: url(../images/scale-screen.svg); 191 | top: -1.5rem; 192 | right: 2rem; 193 | } 194 | 195 | /******************************* Footer *******************************/ 196 | footer { 197 | font-family: var(--font-light); 198 | background-color: black; 199 | color: white; 200 | font-size: 1.2rem; 201 | padding: 0.6rem; 202 | display: flex; 203 | } 204 | 205 | i:first-of-type { 206 | margin-left: 0.5rem; 207 | } 208 | 209 | i { 210 | padding: 0.3em; 211 | color: white; 212 | } 213 | 214 | /******************************* Media queries *******************************/ 215 | @media screen and (max-width: 1040px) { 216 | .scale { 217 | display: none; 218 | } 219 | } 220 | 221 | @media screen and (max-width: 670px) { 222 | .link-field { 223 | --text-field-width: 90vw; 224 | } 225 | } 226 | 227 | @media screen and (max-width: 524px) { 228 | h1 { 229 | --font-size-header: 2.5rem; 230 | } 231 | 232 | .link-field { 233 | padding: 0.15em; 234 | } 235 | 236 | .get-video-btn { 237 | padding: 0.5em; 238 | --font-size-body: 1.1rem; 239 | } 240 | 241 | .videos { 242 | width: 100%; 243 | padding: 0 0.7em; 244 | } 245 | 246 | .videoWrapper { 247 | padding-bottom: 50%; 248 | width: 95vw; 249 | } 250 | } 251 | 252 | @media screen and (max-width: 450px) { 253 | .videos { 254 | width: 100%; 255 | padding: 0; 256 | } 257 | 258 | .videoWrapper { 259 | padding-bottom: 45%; 260 | width: 100vw; 261 | } 262 | } 263 | 264 | @media screen and (max-width: 400px) { 265 | .videoWrapper { 266 | padding-bottom: 43%; 267 | } 268 | .github-code { 269 | font-size: 1.8rem; 270 | } 271 | } 272 | 273 | @media screen and (max-width: 365px) { 274 | footer { 275 | flex-direction: column; 276 | } 277 | 278 | .socials i:first-of-type { 279 | margin: 0; 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /frontend/src/fonts/Gontserrat-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SrikarKSV/MultiTube/94f09815203d4155a84a59259cad49ea83610b94/frontend/src/fonts/Gontserrat-Light.ttf -------------------------------------------------------------------------------- /frontend/src/fonts/Gontserrat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SrikarKSV/MultiTube/94f09815203d4155a84a59259cad49ea83610b94/frontend/src/fonts/Gontserrat-Regular.ttf -------------------------------------------------------------------------------- /frontend/src/images/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SrikarKSV/MultiTube/94f09815203d4155a84a59259cad49ea83610b94/frontend/src/images/favicon.png -------------------------------------------------------------------------------- /frontend/src/images/scale-screen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/js/app.js: -------------------------------------------------------------------------------- 1 | const videoBtn = document.querySelector(".get-video-btn"); 2 | const videoSection = document.querySelector(".videos"); 3 | const closeBtns = document.querySelectorAll(".close"); 4 | const error404Wrapper = document.querySelector(".error-404-container"); 5 | const error403Wrapper = document.querySelector(".error-403-container"); 6 | const infoWrapper = document.querySelector(".info"); 7 | const showInfoBtn = document.querySelector(".show-info"); 8 | const moreBtn = document.querySelector(".more"); 9 | const loaders = document.querySelectorAll(".loader"); 10 | const invalidLinksContainer = document.querySelector(".invalid-links"); 11 | const duplicateLinksContainer = document.querySelector(".duplicate-links"); 12 | const youtubeLinkRegex = /^(https:\/\/)?(www\.)?(m\.)?youtube\.com\/(watch\?v=\w|playlist\?list=\w|channel\/\w|user\/\w)/; 13 | const allVideoLinks = []; 14 | let duplicateLinks = []; 15 | let idList = []; 16 | let currentNextPageToken = null; 17 | let invalidLinks = []; 18 | 19 | videoBtn.addEventListener("click", handleVideoBtn); 20 | moreBtn.addEventListener("click", fetchVideos); 21 | closeBtns.forEach((closeBtn) => { 22 | closeBtn.addEventListener("click", handleErrorCloseBtn); 23 | }); 24 | videoSection.addEventListener("click", handleVideoControls); 25 | 26 | function handleVideoBtn(e) { 27 | e.preventDefault(); 28 | const inputField = document.querySelector(".link-field"); 29 | const inputValue = inputField.value; 30 | if (!inputValue.length) return; 31 | currentNextPageToken = null; 32 | idList = []; 33 | 34 | const inputLinks = inputValue.split(","); 35 | inputLinks.forEach((inputLink) => { 36 | inputLink = inputLink.trim(); 37 | // Skipping if empty string provided 38 | if (!inputLink.length) return; 39 | let validBool = youtubeLinkRegex.test(inputLink); 40 | if (validBool) { 41 | // Checking if the link is already entered 42 | if (allVideoLinks.includes(inputLink)) { 43 | duplicateLinks.includes(inputLink) 44 | ? null 45 | : duplicateLinks.push(inputLink); 46 | return; 47 | } else { 48 | allVideoLinks.push(inputLink); 49 | } 50 | 51 | // Distributing the links into their categories 52 | if (inputLink.includes("watch")) { 53 | videoLinkIframes(inputLink); 54 | } else if (inputLink.includes("playlist")) { 55 | idList.push(["p", inputLink]); 56 | } else if (inputLink.includes("channel") || inputLink.includes("user")) { 57 | idList.push(["c", inputLink]); 58 | } 59 | } else { 60 | invalidLinks.push(inputLink); 61 | } 62 | }); 63 | inputField.value = ""; 64 | invalidLinks.length || duplicateLinks.length 65 | ? showInvalidLinks(invalidLinks) 66 | : null; 67 | fetchVideos(); 68 | } 69 | 70 | // Handle video controls 71 | function handleVideoControls(e) { 72 | const videoWrapper = e.target.closest(".videoWrapper"); 73 | if (e.target.classList.contains("remove")) { 74 | videoWrapper.classList.add("remove-video"); 75 | videoWrapper.addEventListener( 76 | "transitionend", 77 | () => { 78 | videoWrapper.remove(); 79 | }, 80 | { once: true } 81 | ); 82 | } else { 83 | const videoWrapperClone = videoWrapper.cloneNode(true); 84 | const scaleBtn = videoWrapperClone.querySelector(".scale"); 85 | const removeBtn = videoWrapperClone.querySelector(".remove"); 86 | removeBtn.addEventListener("click", handleVideoControls); 87 | scaleBtn.remove(); 88 | videoWrapperClone.classList.add("scale-video"); 89 | const videoSection = videoWrapper.closest(".videos"); 90 | videoSection.insertAdjacentElement("beforebegin", videoWrapperClone); 91 | } 92 | } 93 | 94 | // Fetching and Creating Iframes 95 | function fetchVideos() { 96 | if (idList.length < 1) return; 97 | const categoryLink = idList[0]; 98 | if (categoryLink[0] === "p") { 99 | playlistLinkIframes(categoryLink[1], currentNextPageToken); 100 | } else { 101 | channelLinkIframes(categoryLink[1], currentNextPageToken); 102 | } 103 | } 104 | 105 | async function getChannelOrPlaylistVideos( 106 | category, 107 | id, 108 | nextPageToken, 109 | directLink 110 | ) { 111 | // turn loader on 112 | loaders.forEach((loader) => { 113 | loader.classList.remove("hidden"); 114 | }); 115 | const response = await fetch( 116 | `https://srikar18.pythonanywhere.com/${category}?id=${id}&nextPageToken=${nextPageToken}` 117 | ); 118 | const data = await response.json(); 119 | try { 120 | const videoIds = data.video_ids; 121 | currentNextPageToken = data.nextPageToken; 122 | videoIds.forEach((videoId) => { 123 | const iframeHtml = embedVideoId(videoId); 124 | const iframHtmlFragment = document 125 | .createRange() 126 | .createContextualFragment(iframeHtml); 127 | videoSection.appendChild(iframHtmlFragment); 128 | }); 129 | } catch { 130 | const errorCode = data.error; 131 | // If it is a wrong link 132 | if (Number(errorCode) === 404) { 133 | showInvalidLinks([directLink]); 134 | // Invalid links is removed from all videos list 135 | const invalidLinkIndex = allVideoLinks.findIndex( 136 | (allVideoLink) => allVideoLink === directLink 137 | ); 138 | allVideoLinks.splice(invalidLinkIndex, 1); 139 | idList.unshift(); 140 | } else { 141 | // Else it is a server error 142 | error403Wrapper.classList.add("open"); 143 | } 144 | } 145 | if (currentNextPageToken) { 146 | moreBtn.style.display = "block"; 147 | } else { 148 | if (idList.length > 1) { 149 | moreBtn.style.display = "block"; 150 | currentNextPageToken = null; 151 | idList.shift(); 152 | } else { 153 | moreBtn.style.display = "none"; 154 | } 155 | } 156 | // turn the loader off 157 | loaders.forEach((loader) => { 158 | loader.classList.add("hidden"); 159 | }); 160 | } 161 | 162 | function channelLinkIframes(channelLink, nextPageToken) { 163 | const channelId = channelLink.split("/").slice(-1); 164 | getChannelOrPlaylistVideos("channel", channelId, nextPageToken, channelLink); 165 | } 166 | 167 | function playlistLinkIframes(playlistLink, nextPageToken) { 168 | const playlistId = playlistLink.split("=")[1]; 169 | getChannelOrPlaylistVideos( 170 | "playlist", 171 | playlistId, 172 | nextPageToken, 173 | playlistLink 174 | ); 175 | } 176 | 177 | function videoLinkIframes(videoLink) { 178 | const videoId = videoLink.split("=")[1].split("&")[0]; 179 | const iframeHtml = embedVideoId(videoId); 180 | const iframHtmlFragment = document 181 | .createRange() 182 | .createContextualFragment(iframeHtml); 183 | videoSection.appendChild(iframHtmlFragment); 184 | } 185 | 186 | function embedVideoId(videoId) { 187 | return `
188 | 189 | 190 |

Loading...

191 | 195 |
`; 196 | } 197 | 198 | // Handle Errors 199 | function handleErrorCloseBtn() { 200 | error403Wrapper.classList.remove("open"); 201 | error404Wrapper.classList.remove("open"); 202 | infoWrapper.classList.remove("open"); 203 | setTimeout(removeInvalidLinks, 500); 204 | } 205 | 206 | function removeInvalidLinks() { 207 | invalidLinksContainer.classList.add("hidden"); 208 | duplicateLinksContainer.classList.add("hidden"); 209 | } 210 | 211 | // This function called at 2 scenarios, one is if there is an invalid link 212 | // at input or playlist link was wrong and second is when duplicate links are entered 213 | function showInvalidLinks(iLinks) { 214 | const errorEl = document.querySelector(".error-404-container"); 215 | if (iLinks.length >= 1) { 216 | invalidLinksContainer.classList.remove("hidden"); 217 | const invalidLinksUl = document.querySelector(".invalid-link"); 218 | // Clearing old links 219 | if (!errorEl.classList.contains("open")) { 220 | invalidLinksUl.innerHTML = ""; 221 | } 222 | iLinks.forEach((inavlidLink) => { 223 | invalidLinksUl.innerHTML += `
  • ${inavlidLink}
  • `; 224 | }); 225 | invalidLinks = []; 226 | } 227 | if (duplicateLinks.length >= 1) { 228 | duplicateLinksContainer.classList.remove("hidden"); 229 | const duplicateLinksUl = document.querySelector(".duplicate-link"); 230 | // Clearing old links 231 | duplicateLinksUl.innerHTML = ""; 232 | duplicateLinks.forEach((duplicateLink) => { 233 | duplicateLinksUl.innerHTML += `
  • ${duplicateLink}
  • `; 234 | }); 235 | duplicateLinks = []; 236 | } 237 | errorEl.classList.add("open"); 238 | } 239 | 240 | showInfoBtn.addEventListener("click", () => { 241 | infoWrapper.classList.toggle("open"); 242 | }); 243 | 244 | // Error alert window will be closed 245 | window.addEventListener("keyup", (e) => { 246 | if (e.key === "Escape") { 247 | handleErrorCloseBtn(); 248 | } 249 | }); 250 | 251 | import "regenerator-runtime/runtime"; 252 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multitube", 3 | "version": "1.0.0", 4 | "description": "Watch all the videos of a youtube playlist/channel or multiple videos or mix of both in single page!", 5 | "main": "index.html", 6 | "scripts": { 7 | "start": "parcel ./frontend/index.html", 8 | "build": "parcel build ./frontend/index.html" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/SrikarKSV/MultiTube.git" 13 | }, 14 | "author": "Srikar KSV", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/SrikarKSV/MultiTube/issues" 18 | }, 19 | "homepage": "https://github.com/SrikarKSV/MultiTube#readme", 20 | "devDependencies": { 21 | "autoprefixer": "^9.8.6", 22 | "parcel-bundler": "^1.12.4", 23 | "regenerator-runtime": "^0.13.7" 24 | }, 25 | "browserslist": [ 26 | "> 1%", 27 | "not dead", 28 | "last 2 versions" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2020.6.20 2 | chardet==3.0.4 3 | click==7.1.2 4 | Flask==1.1.2 5 | Flask-Cors==3.0.9 6 | idna==2.10 7 | itsdangerous==1.1.0 8 | Jinja2==2.11.2 9 | MarkupSafe==1.1.1 10 | requests==2.24.0 11 | six==1.15.0 12 | urllib3==1.25.11 13 | Werkzeug==1.0.1 14 | -------------------------------------------------------------------------------- /server/get_videos.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | class GetVideoId: 4 | def __init__(self, api_key: str) -> None: 5 | self.API_KEY = api_key # os.environ.get("MultiTubeYTKEY") 6 | 7 | def get_playlist_videos(self, playlist_id: str, next_page_token: str) -> dict: 8 | params = { 9 | "key": self.API_KEY, 10 | "playlistId": playlist_id, 11 | "part": "contentDetails", 12 | "maxResults": "15", 13 | "pageToken": next_page_token, 14 | } 15 | 16 | r = requests.get( 17 | "https://www.googleapis.com/youtube/v3/playlistItems", params=params 18 | ) 19 | 20 | playlist_data = r.json() 21 | try: 22 | video_ids = [] 23 | for video_id in playlist_data["items"]: 24 | video_id = video_id["contentDetails"]["videoId"] 25 | video_ids.append(video_id) 26 | 27 | next_page_token = playlist_data.get("nextPageToken") 28 | 29 | return {"video_ids": video_ids, "nextPageToken": next_page_token} 30 | except KeyError: 31 | error_code = playlist_data["error"]["code"] 32 | return {"error": error_code} 33 | 34 | def get_channel_videos(self, channel_id: str, next_page_token: str) -> dict: 35 | try: 36 | id = "id" if channel_id[:2] == "UC" else "forUsername" 37 | except TypeError: 38 | return {"error": "Please provide a channel ID"} 39 | 40 | params = { 41 | "key": self.API_KEY, 42 | id: channel_id, 43 | "part": "contentDetails", 44 | "pageToken": next_page_token, 45 | } 46 | 47 | r = requests.get( 48 | "https://www.googleapis.com/youtube/v3/channels", params=params 49 | ) 50 | 51 | channel_data = r.json() 52 | try: 53 | upload_id = channel_data["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"] 54 | except KeyError: 55 | return {"error": 403} if channel_data.get("error") else {"error": 404} 56 | 57 | videos_ids = self.get_playlist_videos(upload_id, next_page_token) 58 | return videos_ids 59 | -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, jsonify 2 | from get_videos import GetVideoId 3 | import os 4 | from flask_cors import cross_origin 5 | 6 | app = Flask(__name__) 7 | 8 | 9 | @app.route("/") 10 | def home(): 11 | return "Hello World" 12 | 13 | 14 | @app.route("/playlist") 15 | @cross_origin() 16 | def playlist(): 17 | api_key = request.args.get("apikey") 18 | get_videos_id = ( 19 | GetVideoId(api_key) if api_key else GetVideoId(os.environ.get("MultiTubeYTKEY")) 20 | ) 21 | playlist_id = request.args.get("id") 22 | next_page_token = request.args.get("nextPageToken") 23 | next_page_token = None if next_page_token == "null" else next_page_token 24 | return jsonify(get_videos_id.get_playlist_videos(playlist_id, next_page_token)) 25 | 26 | 27 | @app.route("/channel") 28 | @cross_origin() 29 | def channel(): 30 | api_key = request.args.get("apikey") 31 | get_videos_id = ( 32 | GetVideoId(api_key) if api_key else GetVideoId(os.environ.get("MultiTubeYTKEY")) 33 | ) 34 | channel_id = request.args.get("id") 35 | next_page_token = request.args.get("nextPageToken") 36 | next_page_token = None if next_page_token == "null" else next_page_token 37 | return jsonify(get_videos_id.get_channel_videos(channel_id, next_page_token)) 38 | 39 | 40 | if __name__ == "__main__": 41 | app.run(debug=True) 42 | --------------------------------------------------------------------------------