├── .gitattributes ├── .gitignore ├── setup.cfg ├── tox.ini ├── setup.py ├── .pre-commit-config.yaml ├── LICENSE ├── tests ├── test_channel_to_playlist.py └── fixtures │ └── playlist.json ├── README.md └── channel_to_playlist.py /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/fixtures/*.json -diff 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.egg-info/ 2 | /.channel_to_playlist-oauth2-credentials.json 3 | /.venv/ 4 | /client_secrets.json 5 | /playlist-* 6 | __pycache__/ 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 99 3 | 4 | [isort] 5 | lines_after_imports = 2 6 | known_first_party = 7 | channel_to_playlist 8 | default_section = THIRDPARTY 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py312{,-lowest} 3 | 4 | [testenv] 5 | deps = 6 | pytest 7 | 8 | lowest: google-api-python-client==2.129.0 9 | lowest: google_auth_oauthlib==1.2.0 10 | lowest: python-dateutil==2.9.0 11 | 12 | commands = pytest 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name="YouTubeChannelToPlaylist", 6 | version="4.0.0", 7 | license="MIT", 8 | author="Robbie Clarken", 9 | author_email="robbie.clarken@gmail.com", 10 | url="https://github.com/RobbieClarken/youtube-channel-to-playlist", 11 | py_modules=["channel_to_playlist"], 12 | install_requires=[ 13 | "google-api-python-client>=2.129.0,<3", 14 | "google_auth_oauthlib>=1.2.0,<2", 15 | "python-dateutil>=2.9.0,<3", 16 | ], 17 | entry_points={"console_scripts": ["channel_to_playlist=channel_to_playlist:main"]}, 18 | ) 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-isort 3 | rev: v5.10.1 4 | hooks: 5 | - id: isort 6 | 7 | - repo: https://github.com/ambv/black 8 | rev: '24.4.2' 9 | hooks: 10 | - id: black 11 | entry: python3 -m black --line-length 99 12 | 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v4.6.0 15 | hooks: 16 | - id: trailing-whitespace 17 | - id: end-of-file-fixer 18 | - id: debug-statements 19 | 20 | - repo: https://github.com/PyCQA/flake8 21 | rev: '7.0.0' 22 | hooks: 23 | - id: flake8 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Robbie Clarken 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_channel_to_playlist.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | from pathlib import Path 3 | 4 | import pytest 5 | from apiclient.discovery import build 6 | from apiclient.http import HttpMock 7 | from dateutil import tz 8 | 9 | import channel_to_playlist 10 | 11 | 12 | FIXTURES = Path(__file__).parent / "fixtures" 13 | VIDEO_IDS = ["M1-eVW6Tboc", "ZBpSxpvZoW0", "wuzCwOO7QDs"] 14 | PLAYLIST_ID = "PLlgnub_DBR_CJFqRj-Gcx4_nmybHAntki" 15 | 16 | 17 | @pytest.fixture 18 | def service(): 19 | http = HttpMock(FIXTURES / "discovery.json") 20 | yield build("youtube", "v3", http=http, developerKey="") 21 | 22 | 23 | def test_parse_args(): 24 | args = channel_to_playlist._parse_args( 25 | [ 26 | "--secrets", 27 | "secrets-1", 28 | "--allow-duplicates", 29 | "--after", 30 | "2018-01-02", 31 | "--before", 32 | "2019-01-02", 33 | "channel-id-1", 34 | "playlist-id-1", 35 | ] 36 | ) 37 | assert args.secrets == "secrets-1" 38 | assert args.allow_duplicates is True 39 | assert args.published_after == datetime(2018, 1, 2, tzinfo=tz.UTC) 40 | assert args.published_before == datetime(2019, 1, 2, tzinfo=tz.UTC) 41 | assert args.channel_id == "channel-id-1" 42 | assert args.playlist_id == "playlist-id-1" 43 | 44 | 45 | def test_parse_args_defaults(): 46 | args = channel_to_playlist._parse_args(["channel-id-1", "playlist-id-1"]) 47 | assert args.secrets == "client_secrets.json" 48 | assert args.allow_duplicates is False 49 | assert args.published_after is None 50 | assert args.published_before is None 51 | 52 | 53 | def test_parse_args_handles_timezones(): 54 | args = channel_to_playlist._parse_args( 55 | [ 56 | "--after", 57 | "2018-01-02T03:04:05+10:00", 58 | "--before", 59 | "2019-01-02T03:04:05+10:00", 60 | "channel-id-1", 61 | "playlist-id-1", 62 | ] 63 | ) 64 | tzinfo = timezone(timedelta(hours=10)) 65 | assert args.published_after == datetime(2018, 1, 2, 3, 4, 5, tzinfo=tzinfo) 66 | assert args.published_before == datetime(2019, 1, 2, 3, 4, 5, tzinfo=tzinfo) 67 | 68 | 69 | def test_get_playlist_video_ids(service): 70 | http = HttpMock(FIXTURES / "playlist.json") 71 | video_ids = channel_to_playlist.get_playlist_video_ids(service, PLAYLIST_ID, http=http) 72 | assert video_ids == VIDEO_IDS 73 | 74 | 75 | def test_get_playlist_video_ids_with_published_after(service): 76 | http = HttpMock(FIXTURES / "playlist.json") 77 | dt = datetime(2018, 11, 4, 4, 25, 44, tzinfo=tz.UTC) 78 | video_ids = channel_to_playlist.get_playlist_video_ids( 79 | service, PLAYLIST_ID, published_after=dt, http=http 80 | ) 81 | assert video_ids == VIDEO_IDS[1:] 82 | 83 | 84 | def test_get_playlist_video_ids_with_published_before(service): 85 | http = HttpMock(FIXTURES / "playlist.json") 86 | dt = datetime(2018, 11, 4, 4, 27, 10, tzinfo=tz.UTC) 87 | video_ids = channel_to_playlist.get_playlist_video_ids( 88 | service, PLAYLIST_ID, published_before=dt, http=http 89 | ) 90 | assert video_ids == VIDEO_IDS[:-1] 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YouTube Channel to Playlist 2 | 3 | [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/RobbieClarken/youtube-channel-to-playlist/blob/master/LICENSE) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 5 | 6 | 7 | Keeping track of which YouTube videos you have watched can be tricky, 8 | especially because the "WATCHED" indicator seems to be ephemeral. This script 9 | allows you to add all videos from a YouTube channel to a playlist that you 10 | control so you can order them as you please and remove them once they've been 11 | watched. 12 | 13 | # Installation 14 | 15 | Requirements: Python 3.12 or later. 16 | 17 | 1. Install this application with pip: 18 | 19 | ```bash 20 | python3 -m pip install git+https://github.com/RobbieClarken/youtube-channel-to-playlist 21 | ``` 22 | 23 | 2. Create a project through the [Google Cloud Console](https://console.cloud.google.com/). 24 | 3. Enable your project to use the YouTube Data API v3 via the 25 | [APIs & Services Dashboard](https://console.cloud.google.com/apis/dashboard). 26 | 4. Configure an 27 | [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent): 28 | - On the _Scopes_ page of the app registration, add the 29 | `https://www.googleapis.com/auth/youtube` scope. 30 | - On the _Test users_ page add your email. 31 | 5. Create an OAuth Client ID through the 32 | [Credentials](https://console.cloud.google.com/apis/credentials) page under APIs & 33 | Services, selecting: 34 | - Application type: Desktop app 35 | 7. Download the OAuth client secrets JSON file from the 36 | [Credentials](https://console.cloud.google.com/apis/credentials) page and 37 | rename it to `client_secrets.json`. 38 | 39 | # Usage 40 | 41 | ```bash 42 | channel_to_playlist --secrets client_secrets.json source-channel-id target-playlist-id 43 | ``` 44 | 45 | where `source-channel-id` and `target-playlist-id` can be found from the URLs of 46 | the YouTube channel and playlist. To find the channel id of a YouTube account 47 | like `https://www.youtube.com/@AntsCanada`: 48 | 1. Click on the channel description text. 49 | 2. Scroll down to Channel Details. 50 | 3. Click _Share Channel > Copy channel ID_. 51 | 52 | For example, to copy videos from the [PyCon 2015 Channel](https://www.youtube.com/channel/UCgxzjK6GuOHVKR_08TT4hJQ) 53 | to [this playlist](https://www.youtube.com/playlist?list=PLlgnub_DBR_CszAWpJypwst0OFDxW6jOJ) 54 | you would run: 55 | 56 | ```bash 57 | channel_to_playlist --secrets client_secrets.json UCgxzjK6GuOHVKR_08TT4hJQ PLlgnub_DBR_CszAWpJypwst0OFDxW6jOJ 58 | ``` 59 | 60 | If you only want to add videos published after or before a certain date, you can use the `--after` 61 | and `--before` options: 62 | 63 | 64 | ```bash 65 | channel_to_playlist --after 2018-05-21 --before 2018-06-30 UCgxzjK6GuOHVKR_08TT4hJQ PLlgnub_DBR_CszAWpJypwst0OFDxW6jOJ 66 | ``` 67 | 68 | The script will store the video IDs that are added to the playlist in a file 69 | and skip these videos if it is run again. This allows you to re-run the script 70 | when new videos are uploaded to the channel. 71 | 72 | To add videos which already exist in the playlist, use the `--allow-duplicates` switch, as shown in the following example: 73 | 74 | ```bash 75 | channel_to_playlist --secrets client_secrets.json UCgxzjK6GuOHVKR_08TT4hJQ PLlgnub_DBR_CszAWpJypwst0OFDxW6jOJ --allow-duplicates 76 | ``` 77 | -------------------------------------------------------------------------------- /tests/fixtures/playlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "youtube#playlistItemListResponse", 3 | "etag": "\"XI7nbFXulYBIpL0ayR_gDh3eu1k/Oc5RHtAa6C6esch_v3Pb-GVRg9E\"", 4 | "pageInfo": { 5 | "totalResults": 3, 6 | "resultsPerPage": 50 7 | }, 8 | "items": [ 9 | { 10 | "kind": "youtube#playlistItem", 11 | "etag": "\"XI7nbFXulYBIpL0ayR_gDh3eu1k/rHAJTc8mfxTRJbu4EoI6g6VCA3k\"", 12 | "id": "UExsZ251Yl9EQlJfQ0pGcVJqLUdjeDRfbm15YkhBbnRraS41NkI0NEY2RDEwNTU3Q0M2", 13 | "snippet": { 14 | "publishedAt": "2018-11-04T04:25:43.000Z", 15 | "channelId": "UCDbgKqaxGiGnX89vIGFqxPA", 16 | "title": "test-fixture-video-1", 17 | "description": "", 18 | "thumbnails": { 19 | "default": { 20 | "url": "https://i.ytimg.com/vi/M1-eVW6Tboc/default.jpg", 21 | "width": 120, 22 | "height": 90 23 | }, 24 | "medium": { 25 | "url": "https://i.ytimg.com/vi/M1-eVW6Tboc/mqdefault.jpg", 26 | "width": 320, 27 | "height": 180 28 | }, 29 | "high": { 30 | "url": "https://i.ytimg.com/vi/M1-eVW6Tboc/hqdefault.jpg", 31 | "width": 480, 32 | "height": 360 33 | }, 34 | "standard": { 35 | "url": "https://i.ytimg.com/vi/M1-eVW6Tboc/sddefault.jpg", 36 | "width": 640, 37 | "height": 480 38 | } 39 | }, 40 | "channelTitle": "Robbie Clarken", 41 | "playlistId": "PLlgnub_DBR_CJFqRj-Gcx4_nmybHAntki", 42 | "position": 0, 43 | "resourceId": { 44 | "kind": "youtube#video", 45 | "videoId": "M1-eVW6Tboc" 46 | } 47 | } 48 | }, 49 | { 50 | "kind": "youtube#playlistItem", 51 | "etag": "\"XI7nbFXulYBIpL0ayR_gDh3eu1k/AQswakC-R3bQfpjXa6ON1nWoT30\"", 52 | "id": "UExsZ251Yl9EQlJfQ0pGcVJqLUdjeDRfbm15YkhBbnRraS4yODlGNEE0NkRGMEEzMEQy", 53 | "snippet": { 54 | "publishedAt": "2018-11-04T04:26:27.000Z", 55 | "channelId": "UCDbgKqaxGiGnX89vIGFqxPA", 56 | "title": "test-fixture-video-2", 57 | "description": "", 58 | "thumbnails": { 59 | "default": { 60 | "url": "https://i.ytimg.com/vi/ZBpSxpvZoW0/default.jpg", 61 | "width": 120, 62 | "height": 90 63 | }, 64 | "medium": { 65 | "url": "https://i.ytimg.com/vi/ZBpSxpvZoW0/mqdefault.jpg", 66 | "width": 320, 67 | "height": 180 68 | }, 69 | "high": { 70 | "url": "https://i.ytimg.com/vi/ZBpSxpvZoW0/hqdefault.jpg", 71 | "width": 480, 72 | "height": 360 73 | }, 74 | "standard": { 75 | "url": "https://i.ytimg.com/vi/ZBpSxpvZoW0/sddefault.jpg", 76 | "width": 640, 77 | "height": 480 78 | } 79 | }, 80 | "channelTitle": "Robbie Clarken", 81 | "playlistId": "PLlgnub_DBR_CJFqRj-Gcx4_nmybHAntki", 82 | "position": 1, 83 | "resourceId": { 84 | "kind": "youtube#video", 85 | "videoId": "ZBpSxpvZoW0" 86 | } 87 | } 88 | }, 89 | { 90 | "kind": "youtube#playlistItem", 91 | "etag": "\"XI7nbFXulYBIpL0ayR_gDh3eu1k/asZbbZGMMPBMsiHafhr8S7EzuZM\"", 92 | "id": "UExsZ251Yl9EQlJfQ0pGcVJqLUdjeDRfbm15YkhBbnRraS4wMTcyMDhGQUE4NTIzM0Y5", 93 | "snippet": { 94 | "publishedAt": "2018-11-04T04:27:10.000Z", 95 | "channelId": "UCDbgKqaxGiGnX89vIGFqxPA", 96 | "title": "test-fixture-video-3", 97 | "description": "", 98 | "thumbnails": { 99 | "default": { 100 | "url": "https://i.ytimg.com/vi/wuzCwOO7QDs/default.jpg", 101 | "width": 120, 102 | "height": 90 103 | }, 104 | "medium": { 105 | "url": "https://i.ytimg.com/vi/wuzCwOO7QDs/mqdefault.jpg", 106 | "width": 320, 107 | "height": 180 108 | }, 109 | "high": { 110 | "url": "https://i.ytimg.com/vi/wuzCwOO7QDs/hqdefault.jpg", 111 | "width": 480, 112 | "height": 360 113 | }, 114 | "standard": { 115 | "url": "https://i.ytimg.com/vi/wuzCwOO7QDs/sddefault.jpg", 116 | "width": 640, 117 | "height": 480 118 | } 119 | }, 120 | "channelTitle": "Robbie Clarken", 121 | "playlistId": "PLlgnub_DBR_CJFqRj-Gcx4_nmybHAntki", 122 | "position": 2, 123 | "resourceId": { 124 | "kind": "youtube#video", 125 | "videoId": "wuzCwOO7QDs" 126 | } 127 | } 128 | } 129 | ] 130 | } 131 | -------------------------------------------------------------------------------- /channel_to_playlist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Add videos from a YouTube channel to a playlist. 4 | 5 | Usage: 6 | 7 | channel_to_playlist.py source-channel-id target-playlist-id 8 | 9 | """ 10 | import os 11 | import sys 12 | import warnings 13 | from argparse import ArgumentParser 14 | from http import HTTPStatus 15 | 16 | import dateutil.parser 17 | from apiclient.discovery import build 18 | from apiclient.errors import HttpError 19 | from dateutil import tz 20 | from google_auth_oauthlib.flow import InstalledAppFlow 21 | 22 | 23 | def _parse_date(string): 24 | dt = dateutil.parser.parse(string) 25 | if dt.tzinfo is None: 26 | dt = dt.replace(tzinfo=tz.UTC) 27 | return dt 28 | 29 | 30 | def get_authenticated_service(args): 31 | flow = InstalledAppFlow.from_client_secrets_file( 32 | client_secrets_file=args.secrets, 33 | scopes=["https://www.googleapis.com/auth/youtube"], 34 | ) 35 | credentials = flow.run_local_server( 36 | host="localhost", 37 | port=8080, 38 | authorization_prompt_message="Please visit this URL: {url}", 39 | success_message="The auth flow is complete; you may close this window.", 40 | open_browser=True, 41 | ) 42 | return build("youtube", "v3", credentials=credentials) 43 | 44 | 45 | def get_channel_upload_playlist_id(youtube, channel_id): 46 | channel_response = youtube.channels().list(id=channel_id, part="contentDetails").execute() 47 | return channel_response["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"] 48 | 49 | 50 | def get_playlist_video_ids( 51 | youtube, playlist_id, *, published_after=None, published_before=None, http=None 52 | ): 53 | request = youtube.playlistItems().list(playlistId=playlist_id, part="snippet", maxResults=50) 54 | items = [] 55 | while request: 56 | response = request.execute(http=http) 57 | items += response["items"] 58 | request = youtube.playlistItems().list_next(request, response) 59 | if published_after is not None: 60 | items = [ 61 | item 62 | for item in items 63 | if _parse_date(item["snippet"]["publishedAt"]) >= published_after 64 | ] 65 | if published_before is not None: 66 | items = [ 67 | item 68 | for item in items 69 | if _parse_date(item["snippet"]["publishedAt"]) < published_before 70 | ] 71 | items.sort(key=lambda item: _parse_date(item["snippet"]["publishedAt"])) 72 | return [item["snippet"]["resourceId"]["videoId"] for item in items] 73 | 74 | 75 | def add_video_to_playlist(youtube, playlist_id, video_id): 76 | try: 77 | youtube.playlistItems().insert( 78 | part="snippet", 79 | body={ 80 | "snippet": { 81 | "playlistId": playlist_id, 82 | "resourceId": {"videoId": video_id, "kind": "youtube#video"}, 83 | } 84 | }, 85 | ).execute() 86 | except HttpError as exc: 87 | if exc.resp.status == HTTPStatus.CONFLICT: 88 | # watch-later playlist don't allow duplicates 89 | raise VideoAlreadyInPlaylistError() 90 | raise 91 | 92 | 93 | def add_to_playlist(youtube, playlist_id, video_ids, added_videos_file, add_duplicates): 94 | existing_videos = get_playlist_video_ids(youtube, playlist_id) 95 | count = len(video_ids) 96 | for video_num, video_id in enumerate(video_ids, start=1): 97 | if video_id in existing_videos and not add_duplicates: 98 | continue 99 | sys.stdout.write("\rAdding video {} of {}".format(video_num, count)) 100 | sys.stdout.flush() 101 | try: 102 | add_video_to_playlist(youtube, playlist_id, video_id) 103 | except VideoAlreadyInPlaylistError: 104 | if add_duplicates: 105 | warnings.warn(f"video {video_id} cannot be added as it is already in the playlist") 106 | if added_videos_file: 107 | added_videos_file.write(video_id + "\n") 108 | existing_videos.append(video_id) 109 | if count: 110 | sys.stdout.write("\n") 111 | 112 | 113 | def _parse_args(args): 114 | argparser = ArgumentParser(description="Add videos from a YouTube channel to a playlist") 115 | argparser.add_argument( 116 | "--secrets", default="client_secrets.json", help="Google API OAuth secrets file" 117 | ) 118 | argparser.add_argument( 119 | "--allow-duplicates", 120 | action="store_true", 121 | help="Add videos even if they are already in the playlist", 122 | ) 123 | argparser.add_argument( 124 | "--after", 125 | type=_parse_date, 126 | dest="published_after", 127 | help="Only add videos published after this date", 128 | ) 129 | argparser.add_argument( 130 | "--before", 131 | type=_parse_date, 132 | dest="published_before", 133 | help="Only add videos published before this date", 134 | ) 135 | argparser.add_argument("channel_id", help="id of channel to copy videos from") 136 | argparser.add_argument("playlist_id", help="id of playlist to add videos to") 137 | parsed = argparser.parse_args(args) 138 | return parsed 139 | 140 | 141 | def main(): 142 | args = _parse_args(sys.argv[1:]) 143 | 144 | youtube = get_authenticated_service(args) 145 | channel_playlist_id = get_channel_upload_playlist_id(youtube, args.channel_id) 146 | video_ids = get_playlist_video_ids( 147 | youtube, 148 | channel_playlist_id, 149 | published_after=args.published_after, 150 | published_before=args.published_before, 151 | ) 152 | added_videos_filename = "playlist-{}-added-videos".format(args.playlist_id) 153 | 154 | if os.path.exists(added_videos_filename): 155 | with open(added_videos_filename) as f: 156 | added_video_ids = set(map(str.strip, f.readlines())) 157 | video_ids = [vid_id for vid_id in video_ids if vid_id not in added_video_ids] 158 | 159 | with open(added_videos_filename, "a") as f: 160 | add_to_playlist(youtube, args.playlist_id, video_ids, f, args.allow_duplicates) 161 | 162 | 163 | class VideoAlreadyInPlaylistError(Exception): 164 | """video already in playlist""" 165 | 166 | 167 | if __name__ == "__main__": 168 | main() 169 | --------------------------------------------------------------------------------