├── .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 | 
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 |
32 | Enter Video Links
33 |
43 |
44 |
45 |
46 |
47 |
48 | more
49 |
53 |
54 |
55 |
56 |
57 |
Close
58 |
59 |
⭐These link(s) is/are invalid:
60 |
61 |
62 |
63 |
⭐Below link(s) is/are not rendered as they are duplicates:
64 |
65 |
66 |
We will continue loading the remaining links(if provided).
67 |
68 |
69 |
70 |
71 |
Close
72 |
There was an error at our servers. Please comeback later.
73 |
74 |
75 |
76 |
77 |
Close
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 | Ex: https://youtube.com/watch?v=8rD9amRSOQY,
85 | youtube.com/playlist?list=PLu8EoSxDXHP5CIFvt9-ze3IngcdAc2xKG,
86 | www.youtube.com/channel/UCCezIgC97PvUuR4_gbFUs5g
87 |
88 |
Note: YouTue channel link of only these patterns are accepted:
89 |
90 | youtube.com/channel/*channel_id*
91 | youtube.com/user/*user_name*
92 |
93 |
For more information visit: github.com/SrikarKSV/MultiTube
95 |
96 |
97 |
98 | Made with ❤️ by Srikar
99 |
107 |
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 |
VIDEO
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 |
--------------------------------------------------------------------------------