Skip to main content

Moving my YouTube Likes from one account to another

I used to have two YouTube accounts, and I wanted to consolidate them into one.

I had two accounts as a way to keep two separate watch histories. I was watching videos about gender and trans stuff before I came out, and I didn’t want them appearing in my main account – say, when I was listening to music at work. That’s less of a concern now than it was five or six years ago, and the lines between them have become blurry. I don’t need two accounts any more.

Because I only use YouTube for watching videos, and not posting, there were only three lists I really wanted to keep: my subscriptions, my Watch Later queue, and my Likes. My subs and watch later were both small enough to copy by hand; the likes were the hard bit – I had about 1500 or so.

There’s no built-in way to move Likes between YouTube accounts, so it was time to break out the YouTube API.

Getting authentication working

The first step was getting some API credentials. This uses the Google Cloud console, which I’m not super familiar with, but YouTube has a lot of quickstart guides and code samples which made the process much easier.

I used the Python quickstart guide, and went through the following steps:

  1. Create a project in the Google Cloud console
  2. Enable the YouTube Data API for that project
  3. Create some OAuth credentials, which came in a JSON file I had to download

At some point during this process, I had to create an OAuth consent screen. If I was publishing this app for the world to use, you’d see this as signing into the app, and it would have to be reviewed by Google. Because I was only writing scripts for me, I was able to mostly skip this step – I left the app with a “testing” status, and just listed my two YouTube accounts as “test users”:

Screenshot of a settings screen in Google Cloud console. The panel is a titled ‘OAuth consent screen’, and there’s a table labelled ‘Test users’. It has two rows with redacted email addresses, and buttons to add/remove users from the table.

After this, I tried to run the sample Python script from Google’s documentation. It didn’t work – it was written for an older version of the Python libraries. In particular, it used flow.run_console(), which uses an authentication method which has been deprecated for over a year. A Stack Overflow answer suggested I use flow.run_local_server(), and that was more successful.

Here’s the first script I got working, which is a modified version of the sample code:

import googleapiclient.discovery  # pip install google-api-python-client==1.7.2
import google_auth_oauthlib.flow  # pip install google-auth-oauthlib==0.4.1


def create_youtube_client(client_secrets_file):
    """
    Given the path to a JSON file with OAuth credentials from the
    Google Cloud console, create an authenticated client.
    """
    api_service_name = "youtube"
    api_version = "v3"
    scopes = ["https://www.googleapis.com/auth/youtube.readonly"]

    flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file(
        client_secrets_file, scopes
    )
    credentials = flow.run_local_server()

    youtube = googleapiclient.discovery.build(
        api_service_name, api_version, credentials=credentials
    )

    return youtube


if __name__ == "__main__":
    youtube = create_youtube_client(
        client_secrets_file="client_secret_12345.apps.googleusercontent.com.json"
    )

    request = youtube.channels().list(
        part="snippet,contentDetails,statistics",
        mine=True
    )
    response = request.execute()

    from pprint import pprint; pprint(response)

When I run this, the script kicks me out into a web browser, where I have to go through the usual Google login screen, and confirm I want to use this app. After I clicked through a few confirmation screens, my browser eventually got to a page that said:

The authentication flow has completed. You may close this window.

and back in my terminal window, the script was running and printing a list of my playlists.

Already this was further than I’d got in the past – I had an authenticated API client, and it was retrieving real data from my YouTube account. Good progress!

Making the authentication better

The authentication code above works, but it has two major issues:

Both of these issues can be solved using the keyring module, which provides a platform-agnostic interface to the system password store (in my case, the login keychain on macOS).

I changed the function to fetch the OAuth client config from the keychain, and to store retrieved credentials in the keychain. When I run it repeatedly, it retrieves the stored credentials rather than sending me back through the in-browser flow.

After running these scripts for a while, I discovered that Google’s OAuth credentials expire after about a week. I wrote some rudimentary code to handle credential expiry – it deletes the stored credentials, and sends me back through the in-browser flow. There are almost certainly better ways to do this, but my simplistic approach worked well enough for my one-off script.

Here’s my updated function:

import datetime
import json

import google.oauth2.credentials
import googleapiclient.discovery  # pip install google-api-python-client==1.7.2
import google_auth_oauthlib.flow  # pip install google-auth-oauthlib==0.4.1
import keyring


def create_youtube_client(label: str):
    """
    Get an authenticated OAuth client for YouTube.

    It gets the OAuth config from the system keychain, and caches
    per-user credentials in the keychain under ("youtube", label).
    """
    api_service_name = "youtube"
    api_version = "v3"
    scopes = ["https://www.googleapis.com/auth/youtube.readonly"]

    # Try to retrieve a stored OAuth access token from the keychain.
    #
    # This saves me going through the in-browser authentication flow
    # if I've already run the script.
    stored_credentials = keyring.get_password("youtube", label)

    if stored_credentials is not None:
        json_credentials = json.loads(stored_credentials)

        if "expiry" in json_credentials:
            expiry = datetime.datetime.fromisoformat(json_credentials["expiry"])
            expiry = expiry.replace(tzinfo=None)
            json_credentials["expiry"] = expir

        credentials = google.oauth2.credentials.Credentials(**json_credentials)

    # If there are no stored credentials, fetch new ones.
    else:
        # Retrieve the OAuth client credentials from the keychain.
        #
        # This contains the contents of the JSON file that I downloaded
        # from the Google Cloud console, but now those credentials aren't
        # just saved as a plaintext file on disk.
        stored_client_secrets = keyring.get_password("youtube", "client_secrets")
        if stored_client_secrets is None:
            raise ValueError("Could not find OAuth client secrets in keychain!")

        flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_config(
            client_config=json.loads(stored_client_secrets), scopes=scopes
        )
        credentials = flow.run_local_server()

        # Save these credentials in the system keychain, so they can be
        # retrieved later.
        keyring.set_password("youtube", label, credentials.to_json())

    youtube = googleapiclient.discovery.build(
        api_service_name, api_version, credentials=credentials
    )

    # The OAuth credentials don't last forever -- they seem to expire after
    # a week.  This is a slightly ropey attempt to work around that.
    #
    # If we call the API and the saved token is expired, just delete
    # it and get new creds -- sending me back through the in-browser flow.
    #
    # Notes:
    #
    #   - There are ways to refresh OAuth tokens that don't involve
    #     sending me back through the in-browser flow, but I didn't
    #     look at them as part of this project.
    #   - Catching all exceptions is a bit broad.  This code should really
    #     retry only if it gets a "credentials expired" exception, and
    #     throw any other exceptions immediately.
    #
    try:
        request = youtube.channels().list(part="snippet", mine=True)
        request.execute()
    except Exception as e:
        keyring.delete_password("youtube", label)
        return create_youtube_client(label)
    else:
        return youtube

This function is more complicated than Google’s sample code, and there are more ways that it could be improved. Authentication is hard!

Actually using the YouTube API

With an authenticated client, it was relatively straightforward to write code that interacts with YouTube’s APIs. I’ve lost the links, but I found snippets of sample code in Google’s documentation that I was able to adapt.

I started by wrapping the create_youtube_client in a class, and writing a function to list all the videos I’d liked:

class YouTubeClient:
    def __init__(self, label: str):
        self.youtube = self.create_youtube_client(label)

    def create_youtube_client(self, label: str):
        

    def get_liked_videos(self):
        """
        Generate a list of videos that this YouTube account has liked.
        """
        kwargs = {"part": "snippet", "playlistId": "LL", "maxResults": "50"}

        while True:
            request = self.youtube.playlistItems().list(**kwargs)
            response = request.execute()

            yield from response["items"]

            try:
                kwargs["pageToken"] = response["nextPageToken"]
            except KeyError:
                break

[Edit, 15 February 2024: the original version of this code called the videos() endpoint and filtered for my likes, but that was only able to see the first 1000 likes. That was fine for this project, where I was gradually deleting the list, but not in general. I’ve changed it to use the playlistItems() API, which seems to return the full set.]

This generates videos in reverse order of liking them – the most recently liked video comes first. The items are large dicts which include various metadata fields about each video, of which the most interesting one to me is the ID:

{'id': 'J-u2aW7T2bw', …}
{'id': 'XPaKAh2zxgk', …}
{'id': '-q7ZVXOU3kM', …}

Then I wrote a couple of methods which like/unlike a video. Because these are modifying data in YouTube, I had to change the scopes to https://www.googleapis.com/auth/youtube, replacing the youtube.readonly scope I’d been using previously.

class YouTubeClient:
    

    def like_video(self, *, video_id):
        """
        Mark a video as "liked" on YouTube.
        """
        request = self.youtube.videos().rate(id=video_id, rating="like")
        response = request.execute()

    def unlike_video(self, *, video_id):
        """
        Remove the "liked" rating from a video on YouTube.
        """
        request = self.youtube.videos().rate(id=video_id, rating="none")
        response = request.execute()

Putting these functions together, I was then able to write a short script which moved my likes from one account to the other:

old_youtube = YouTubeClient(label="old_account")
new_youtube = YouTubeClient(label="new_account")

for video in old_youtube.get_liked_videos():
    video_id = video["id"]

    print("https://www.youtube.com/watch?v={video_id}")
    new_youtube.like_video(video_id=video_id)
    old_youtube.unlike_video(video_id=video_id)

Removing the likes from the old account wasn’t strictly necessary – I was planning to close the account when I was done – but it was an easy way to track the progress, and turned out to be helpful towards the end of the process (more on that below).

Incidentally, around the time I wrote this code, David published a post about writing good programming abstractions, and I think this is a nice example of one. Wrapping these API calls in a couple of named functions doesn’t do anything to help de-duplication, but it does make the intent of the final script much clearer.

Running the code in practice

By and large this code worked extremely well. Almost all of the videos moved across seamlessly, and I could watch it in two side-by-side browser windows – likes appeared in one account as they disappeared from the other. It was substantially quicker and easier than if I’d tried to do it by hand.

I did run into a couple of non-obvious issues:

These were relatively minor issues, and easy to work around. And once I’d finished running this script, I was able to close the old account and throw away this code – but maybe I’ll come back to these notes if I have another interesting idea for using the YouTube API.