Hulu to Disney+ Conversion (Mostly Complete)

Given that we've reached the point where Hulu inside of Disney+ is out of beta and is the home of all future content, I've been trying to put together a method to convert all my existing valid Stream Links. While everything below is coded in PowerShell, I'm sure it could be converted into other languages as necessary. At this point, I'm looking for some assistance in figuring out ways to get around the open issues and create a more final product.


Part 1: Cleanup

Since it's been a while since I've manually checked to see what was removed from Hulu, a process to get rid of those automatically would be beneficial.

Original logic, only here for informational purposes...

The following script goes through all .strmlnk files in a directory (i.e., Movies > Hulu) and all its sub-directories (i.e., TV > Hulu > SHOWNAME > Season 01) and tests to see if the content is available on Hulu. If it is, nothing happens. If it isn't, then it deletes the .strmlnk file. In the case of TV shows, if a season or an entire program empties out, then that directory will also be removed. This also fixes the fact that Channels does not remove empty directories for .strmlnk locations, either, when all files are trashed.

NOTE: This is only testing for Hulu availability. If someone has a link to somewhere else (i.e., Hoopla), the script will essentially ignore it unless it resolves in an error (i.e., 404).

# Get the current directory
$rootDirectory = Get-Location

# Define the condition URL
$conditionUrl = "https://www.hulu.com/live-tv"

# Recursively search for .strmlink files
Get-ChildItem -Path $rootDirectory -Recurse -Filter "*.strmlnk" | ForEach-Object {
    $file = $_
    $url = Get-Content -Path $file.FullName
    $exceptiontest = $false

    # Resolve the URL (you may need to adjust this based on your environment)
    try {
        $resolvedUrl = (Invoke-WebRequest -Uri $url).BaseResponse.ResponseUri.AbsoluteUri
    } catch [System.Net.WebException] {
        # Handle HTTP error (e.g., 404)
        $resolvedUrl = $_.Exception.Response.ResponseUri.AbsoluteUri
        $exceptiontest = $true
    }

    if ($resolvedUrl -eq $conditionUrl -or $exceptiontest) {
        # Condition met: delete the file without prompting
        Remove-Item -Path $file.FullName -Force -Confirm:$false
        Write-Host "Deleted $($file.FullName)"
    } else {
        # Condition unmet: keep the file
        Write-Host "Keeping $($file.FullName)"
    }
}

# Remove empty subdirectories without prompting
Get-ChildItem $rootDirectory -Recurse | Where-Object { $_.Length -eq 0 } | ForEach-Object {
    Remove-Item -Path $_.FullName -Force
    Write-Host "Removed empty directory: $($_.FullName)"
}

Get-ChildItem $rootDirectory -Recurse | Where-Object { $_.PSIsContainer -and @(Get-ChildItem $_.FullName -Recurse -File).Count -eq 0 } | ForEach-Object {
    Remove-Item -Path $_.FullName -Recurse -Force
    Write-Host "Removed empty directory: $($_.FullName)"
}

Output currently looks something like this:

image

image

OPEN ISSUE: If a show has any episodes available, seasons that are not available won't delete because the show resolves as available. For instance, Season 11 of America's Next Top Model is no longer available on Hulu; but if I go to a Stream Link from that season, I end up here:

This is in comparison to an episode of a removed show like Dark Side of the Ring: After Dark or a previously removed movie of any kind:

image

NOTE: This is what the script is testing for... to see if the final destination URL resolves here or in an error.

That said, it is a different story if logged into Hulu. The script is naturally not logged in, but if it could be, an incorrect TV show resolves to https://www.hulu.com/hub/home, which could easily be added as another condition to test.


Part 2: Conversion

The URL change from Hulu to Disney+ is relatively straightforward.

"Original logic, only here for informational purposes...
get-childitem -recurse -include *.strmlnk | select -expand fullname | foreach {(Get-Content $_) -replace "hulu.com/watch","disneyplus.com/play" | Set-Content $_ -NoNewline}

Basically, for something like 12 Years a Slave, on Hulu the Stream Link is https://www.hulu.com/watch/f6a4c981-745e-48ef-aaa0-c857c187ce93. The Disney+ version is pretty much the same, notably using the exact content ID:

OPEN ISSUE: This has worked in 100% of my tests for movies and TV Shows that are within the Hulu area of Disney+. However, there are the occasional programs that have not been converted for whatever reason and will need to remain a Hulu Stream Link for the time being. For example, After Earth (https://www.hulu.com/watch/c4ede94a-532c-4615-bbc0-7a9c27c8c903) is available on Hulu, but not on Disney+,

image

The problem is, similar to Part 1, when not logged in to Disney+ as a script would be, all URLs resolve back to https://www.disneyplus.com, whether or not the content is available on Disney+. That said, if logged in, the URL of only missing content resolves to https://www.disneyplus.com/home and gives this helpful error:

image

Thus, that seems like something we could test for, but first we'd have to find some way for the script to log in first.


And that's about where I am right now. Very open to anyone with insights and ideas on how to make this cleaner!

2 Likes

Okay, you can forget a lot of what I put above in the original details. For simplicity and ease of transport sake, I've changed my mind and started working in Python. At this juncture, I have code that can log into Hulu and the same logic could be applied to Disney+. Thus, I should be able to do everything I originally set out to do.

Deprecated prior code, see next post for correct code...
import os
import platform
import subprocess
import time
import datetime
import getpass

# Get current time for logging
def current_time():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ": "

# Install Chromium and Selenium if not already installed
try:
    from selenium import webdriver
    from selenium.webdriver.chrome.service import Service
    from selenium.webdriver.common.by import By
    from selenium.webdriver.chrome.options import Options
    from selenium.webdriver.support.ui import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC
except ImportError:
    if os.name == "posix":  # Unix-like systems (including macOS)
        os.system("pip3 install chromium")
        os.system("pip3 install selenium")
    else:   
        os.system("pip install chromium")
        os.system("pip install selenium")

# Check if the OS allows popup boxes
try:
    import tkinter as tk
    from tkinter import Tk, simpledialog, IntVar, Radiobutton
except ImportError:
    # If popup boxes are not allowed, do not import tkinter
    pass

#Initialization
credential_flag = True

def get_chrome_executable_path():
    if 'CHROME_BIN' in os.environ:
        return os.environ['CHROME_BIN']

    chrome_executable_path = None

    print(f"{current_time()} System Identified as {platform.system()}. Setting Chrome Executable Path based on this...")

    if platform.system() == 'Linux':
        try:
            chrome_executable_path = subprocess.check_output(['which', 'chromium-browser']).decode().split('\n')[0]
        except subprocess.CalledProcessError:
            pass

        if not chrome_executable_path:
            try:
                chrome_executable_path = subprocess.check_output(['which', 'chromium']).decode().split('\n')[0]
                if not chrome_executable_path:
                    raise Exception('Chromium not found (which chromium)')
            except subprocess.CalledProcessError:
                pass
    elif platform.system() == 'Darwin':
        potential_paths = [
            '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
            '/Applications/Chromium.app/Contents/MacOS/Chromium',
            '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
        ]
        for path in potential_paths:
            if os.path.exists(path):
                chrome_executable_path = path
                break
    elif platform.system() == 'Windows':
        potential_paths = [
            r'C:\Program Files\Google\Chrome\Application\chrome.exe',
            r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
            os.path.join(os.getenv('USERPROFILE'), 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe'),
            os.path.join(os.getenv('USERPROFILE'), 'AppData', 'Local', 'Chromium', 'Application', 'chrome.exe'),
        ]
        for path in potential_paths:
            if os.path.exists(path):
                chrome_executable_path = path
                break
    else:
        raise Exception(f'Unsupported platform: {platform.system()}')

    print(f"{current_time()} Chrome Executable Path found at {chrome_executable_path}.")
    return chrome_executable_path

def create_chrome_driver():
    # Initialize Chrome driver
    chrome_options = webdriver.ChromeOptions()
    
    chrome_arguments = [
        '--headless',
        '--window-size=1920,1080',
        '--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',
        '--disable-web-security',
        '--no-first-run',
        '--disable-infobars',
        '--hide-crash-restore-bubble',
        '--disable-blink-features=AutomationControlled',
        '--hide-scrollbars',
        '--enable-automation',
        '--disable-extensions',
        '--disable-default-apps',
        '--disable-component-update',
        '--disable-component-extensions-with-background-pages',
        '--enable-blink-features=IdleDetection',
        '--no-sandbox',
        '--verbose'
    ]
    for arg in chrome_arguments:
        chrome_options.add_argument(arg)
    
    # Set the path to the Chrome executable (modify this based on your system)
    chrome_options.binary_location = get_chrome_executable_path()  # Use the previously defined function

    # Initialize the Chrome driver
    driver = webdriver.Chrome(options=chrome_options)
    
    return driver

# Fill in field
def get_user_input(prompt):
    if os.name == "posix":  # Unix-like systems (including macOS)
        # Use command line input
        while True:
            user_input = input(prompt)
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")
    else:
        # Use pop-up window
        while True:
            root = Tk()
            root.withdraw()  # Hide the main window
            user_input = simpledialog.askstring("Input", prompt)
            root.destroy()  # Close the pop-up window
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")

# Fill in field (masked)
def get_user_input_masked(prompt):
    if os.name == "posix": # Unix-like systems (including macOS)
        # Use command line input
        while True:
            user_input = getpass.getpass(prompt)
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")
    else:
        # Use pop-up window
        while True:
            root = Tk()
            root.withdraw()  # Hide the main window
            user_input = simpledialog.askstring("Input", prompt, show="*")
            root.destroy()  # Close the pop-up window
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")

# Select an option from list
def get_user_input_select(prompt, options):
    if os.name == "posix": # Unix-like systems (including macOS)
        # Use command line input
        print(prompt)
        for i, option in enumerate(options):
            print(f"{i + 1}. {option}")
        while True:
            try:
                selected_option = int(input("Select an option: "))
                if 1 <= selected_option <= len(options):
                    return options[selected_option - 1]
                else:
                    print("Invalid option. Please try again.")
            except ValueError:
                print("Invalid input. Please enter a number.")
    else:
        # Use pop-up window
        root = tk.Tk()
        root.withdraw()

        # Create a variable to store the selected option
        selected_option = None

        # Create a dialog box with buttons for each option
        dialog = tk.Toplevel(root)
        dialog.title("Select an option")
        dialog.geometry("300x150")

        # Add the prompt to the dialog
        tk.Label(dialog, text=prompt).pack()

        def select_option(option):
            nonlocal selected_option
            selected_option = option
            dialog.destroy()

        for option in options:
            tk.Button(dialog, text=option, command=lambda o=option: select_option(o)).pack()

        # Wait for the user to select an option
        dialog.wait_window()

        # Return the selected option
        return selected_option

def chrome_quit(driver):
    print(f"{current_time()} Closing Chrome...")
    driver.quit()
    print(f"{current_time()} Chrome closed.")

def main():
    global credential_flag

    while True:
        if credential_flag:
            # Prompt user for username (email)
            print(f"{current_time()} Prompting for Hulu username.")
            username = get_user_input("Enter your Hulu username (email address): ")
        
            # Prompt user for password (masked input)
            print(f"{current_time()} Prompting for Hulu password.")
            password = get_user_input_masked("Enter your Hulu password: ")
        else:
            print(f"{current_time()} Reusing previous Hulu username and password.")

        # Initialize the Chrome driver
        driver = create_chrome_driver()
        driver.save_screenshot("hulu_login_screenshot_01.png")
        print(f"{current_time()} Chrome session is running in the background.")

        # Navigate to the Hulu login page
        driver.get("https://auth.hulu.com/login")
        driver.save_screenshot("hulu_login_screenshot_02.png")
        print(f"{current_time()} Navigated to Hulu login page.")

        # Wait for the page to be fully rendered (e.g., wait for the login form to appear)
        print(f"{current_time()} Waiting for page to fully render...")
        wait = WebDriverWait(driver, 10)
        username_field = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "[data-automationid='email-field']")))
        driver.save_screenshot("hulu_login_screenshot_03.png")
        print(f"{current_time()} Page rendered, moving on after 1 second...")

        # Wait for 1 second
        time.sleep(1)

        # Find the username field and enter the username
        username_field = driver.find_element(By.CSS_SELECTOR, "[data-automationid='email-field']")
        username_field.send_keys(username)
        driver.save_screenshot("hulu_login_screenshot_04.png")
        print(f"{current_time()} Entered username. Waiting 1 second...")

        # Wait for 1 second
        time.sleep(1)

        # Find the password field and enter the password
        password_field = driver.find_element(By.CSS_SELECTOR, "[data-automationid='password-field']")
        password_field.send_keys(password)
        driver.save_screenshot("hulu_login_screenshot_05.png")
        print(f"{current_time()} Entered password. Waiting 1 second...")

        # Wait for 1 second
        time.sleep(1)

        # Find the login button and click it
        login_button = driver.find_element(By.CSS_SELECTOR, "[data-automationid='login-button']")
        login_button.click()
        driver.save_screenshot("hulu_login_screenshot_06.png")
        print(f"{current_time()} Clicked login button. Waiting 5 seconds...")

        # Wait for 5 seconds
        time.sleep(5)

        # Check if the six-digit code field is available
        print(f"{current_time()} Checking for verification code request...")
        six_digit_code_field = driver.find_elements(By.CSS_SELECTOR, "[data-automationid='six-digit-code-field']")
        if six_digit_code_field:
            print(f"{current_time()} Verification code request found.")
            driver.save_screenshot("hulu_login_screenshot_07.png")
            verification_code = get_user_input("Enter the six-digit verification code: ")
            six_digit_code_field[0].send_keys(verification_code)
            driver.save_screenshot("hulu_login_screenshot_08.png")
            time.sleep(5)  # Wait for 5 seconds
            if driver.current_url != "https://auth.hulu.com/login":
                print(f"{current_time()} Login successful after verification!")
                driver.save_screenshot("hulu_login_screenshot_09.png")
                break
            else:
                print(f"{current_time()} Login failed. Please check your credentials or verification code.")
                driver.save_screenshot("hulu_login_screenshot_10.png")
        else:
            print(f"{current_time()} No verification code requested. Checking login success...")
            # Check if login was successful (webpage changed)
            if driver.current_url != "https://auth.hulu.com/login":
                print(f"{current_time()} Login successful!")
                driver.save_screenshot("hulu_login_screenshot_11.png")
                break
            else:
                print(f"{current_time()} Login failed, requesting next steps...")
                retry_option = get_user_input_select("Login failed. Do you want to try again?", ["Yes, with same credentials", "Yes, with new credentials", "No, exit"])
                driver.save_screenshot("hulu_login_screenshot_12.png")
                if retry_option == "No, exit":
                    print(f"{current_time()} Exiting...")
                    retry_option = None
                    chrome_quit(driver)
                    break
                elif retry_option == "Yes, with same credentials":
                    print(f"{current_time()} Retrying with same credentials...")
                    credential_flag = False
                    retry_option = None
                    chrome_quit(driver)
                    continue
                elif retry_option == "Yes, with new credentials":
                    print(f"{current_time()} Retrying with new credentials...")
                    credential_flag = True
                    retry_option = None
                    chrome_quit(driver)
                    continue
                else:
                    print(f"{current_time()} Invalid option. Please select a valid option.")

    # Other Actions
    print(f"{current_time()} End of current commands.")

if __name__ == "__main__":
    main()

However, I'm still trying to overcome a couple of basic issues:

  1. Logging in works fine so long as Chrome is visible. Once I make it headless, it fails most of the time, but not all the time. About 5% of the time it seems to work perfectly and I cannot yet narrow in on why. This appears to be same issue in general with people using Channels TVE login for Hulu. Something about being headless seems to infuriate Hulu.
    2. The retry logic of asking if the user wants to use new or original credentials (or quit) works great on the first pass. Every subsequent pass always defaults to the first option no matter what is selected.

If I can get past these two this one things, I'm fairly confident I can get the rest working since I already had that going in PowerShell before.

2 Likes

HuluCleanup.py

import os
import platform
import subprocess
import time
import datetime
import getpass

# Get current time for logging
def current_time():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ": "

# Install Selenium if not already installed
try:
    from selenium import webdriver
    from selenium.webdriver.chrome.service import Service
    from selenium.webdriver.common.by import By
    from selenium.webdriver.chrome.options import Options
    from selenium.webdriver.support.ui import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC
except ImportError:
    if os.name == "posix":  # Unix-like systems (including macOS)
        os.system("pip3 install selenium")
    else:   
        os.system("pip install selenium")

# Check if the OS allows popup boxes
try:
    import tkinter as tk
    from tkinter import Tk, simpledialog, IntVar, Radiobutton
except ImportError:
    # If popup boxes are not allowed, do not import tkinter
    pass

#Initialization
credential_flag = True

def get_chrome_executable_path():
    if 'CHROME_BIN' in os.environ:
        return os.environ['CHROME_BIN']

    chrome_executable_path = None

    print(f"{current_time()} System Identified as {platform.system()}. Setting Chrome Executable Path based on this...")

    if platform.system() == 'Linux':
        try:
            chrome_executable_path = subprocess.check_output(['which', 'chromium-browser']).decode().split('\n')[0]
        except subprocess.CalledProcessError:
            pass

        if not chrome_executable_path:
            try:
                chrome_executable_path = subprocess.check_output(['which', 'chromium']).decode().split('\n')[0]
                if not chrome_executable_path:
                    raise Exception('Chromium not found (which chromium)')
            except subprocess.CalledProcessError:
                pass
    elif platform.system() == 'Darwin':
        potential_paths = [
            '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
            '/Applications/Chromium.app/Contents/MacOS/Chromium',
            '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
        ]
        for path in potential_paths:
            if os.path.exists(path):
                chrome_executable_path = path
                break
    elif platform.system() == 'Windows':
        potential_paths = [
            r'C:\Program Files\Google\Chrome\Application\chrome.exe',
            r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
            os.path.join(os.getenv('USERPROFILE'), 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe'),
            os.path.join(os.getenv('USERPROFILE'), 'AppData', 'Local', 'Chromium', 'Application', 'chrome.exe'),
        ]
        for path in potential_paths:
            if os.path.exists(path):
                chrome_executable_path = path
                break
    else:
        raise Exception(f'Unsupported platform: {platform.system()}')

    print(f"{current_time()} Chrome Executable Path found at {chrome_executable_path}.")
    return chrome_executable_path

def create_chrome_driver():
    # Initialize Chrome driver
    chrome_options = webdriver.ChromeOptions()
    
    chrome_arguments = [
#        '--headless',
        '--window-size=1920,1080',
        '--disable-web-security',
        '--no-first-run',
        '--disable-infobars',
        '--hide-crash-restore-bubble',
        '--disable-blink-features=AutomationControlled',
        '--hide-scrollbars',
        '--enable-automation',
        '--disable-extensions',
        '--disable-default-apps',
        '--disable-component-update',
        '--disable-component-extensions-with-background-pages',
        '--enable-blink-features=IdleDetection',
        '--no-sandbox',
        '--verbose'
    ]
    for arg in chrome_arguments:
        chrome_options.add_argument(arg)
    
    # Set the path to the Chrome executable (modify this based on your system)
    chrome_options.binary_location = get_chrome_executable_path()  # Use the previously defined function

    # Initialize the Chrome driver
    driver = webdriver.Chrome(options=chrome_options)
    
    return driver

# Exit Chrome and kill all Chrome processes
def chrome_quit(driver):
    print(f"{current_time()} Closing Chrome session...")
    driver.quit()
    print(f"{current_time()} Chrome session closed.")

# Fill in field
def get_user_input(prompt):
    if os.name == "posix":  # Unix-like systems (including macOS)
        # Use command line input
        while True:
            user_input = input(prompt)
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")
    else:
        # Use pop-up window
        while True:
            root = Tk()
            root.withdraw()  # Hide the main window
            user_input = simpledialog.askstring("Input", prompt)
            root.destroy()  # Close the pop-up window
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")

# Fill in field (masked)
def get_user_input_masked(prompt):
    if os.name == "posix": # Unix-like systems (including macOS)
        # Use command line input
        while True:
            user_input = getpass.getpass(prompt)
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")
    else:
        # Use pop-up window
        while True:
            root = Tk()
            root.withdraw()  # Hide the main window
            user_input = simpledialog.askstring("Input", prompt, show="*")
            root.destroy()  # Close the pop-up window
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")

# Select an option from list
def get_user_input_select(prompt, options):
    if os.name == "posix": # Unix-like systems (including macOS)
        # Use command line input
        print(prompt)
        for i, option in enumerate(options):
            print(f"{i + 1}. {option}")
        while True:
            try:
                selected_option = int(input("Select an option: "))
                if 1 <= selected_option <= len(options):
                    return options[selected_option - 1]
                else:
                    print("Invalid option. Please try again.")
            except ValueError:
                print("Invalid input. Please enter a number.")
    else:
        # Use pop-up window
        root = tk.Tk()
        root.withdraw()

        # Create a variable to store the selected option
        selected_option = None

        # Create a dialog box with buttons for each option
        dialog = tk.Toplevel(root)
        dialog.title("Select an option")
        dialog.geometry("300x150")

        # Add the prompt to the dialog
        tk.Label(dialog, text=prompt).pack()

        def select_option(option):
            nonlocal selected_option
            selected_option = option
            dialog.destroy()

        for option in options:
            tk.Button(dialog, text=option, command=lambda o=option: select_option(o)).pack()

        # Wait for the user to select an option
        dialog.wait_window()

        # Return the selected option
        return selected_option

# Pick a directory
def get_directory():
    while True:
        if os.name == "posix":  # Unix-like systems (including macOS)
            directory_path = input("Enter a directory path: ")
        else:
            try:
                import tkinter as tk
                from tkinter import filedialog
                root = tk.Tk()
                root.withdraw()  # Hide the main window
                directory_path = filedialog.askdirectory(title="Select a base directory")
            except ImportError:
                directory_path = input("Enter a directory path: ")

        # Validate if a value was entered
        if directory_path:
            break
        else:
            print(f"{current_time()} Error: Please provide a valid directory path.")

    return directory_path

def main():
    global credential_flag

    while True:
        if credential_flag:
            # Prompt user for username (email)
            print(f"{current_time()} Prompting for Hulu username.")
            username = get_user_input("Enter your Hulu username (email address): ")
        
            # Prompt user for password (masked input)
            print(f"{current_time()} Prompting for Hulu password.")
            password = get_user_input_masked("Enter your Hulu password: ")
        else:
            print(f"{current_time()} Reusing previous Hulu username and password.")

        # Initialize the Chrome driver
        driver = create_chrome_driver()
        print(f"{current_time()} Chrome session is running in the background.")

        # Navigate to the Hulu login page
        driver.get("https://auth.hulu.com/login")
        print(f"{current_time()} Navigated to Hulu login page.")

        # Wait for the page to be fully rendered (e.g., wait for the login form to appear)
        print(f"{current_time()} Waiting for page to fully render...")
        wait = WebDriverWait(driver, 10)
        username_field = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "[data-automationid='email-field']")))
        print(f"{current_time()} Page rendered, moving on after 1 second...")

        # Wait for 1 second
        time.sleep(1)

        # Find the username field and enter the username
        username_field = driver.find_element(By.CSS_SELECTOR, "[data-automationid='email-field']")
        username_field.send_keys(username)
        print(f"{current_time()} Entered username. Waiting 1 second...")

        # Wait for 1 second
        time.sleep(1)

        # Find the password field and enter the password
        password_field = driver.find_element(By.CSS_SELECTOR, "[data-automationid='password-field']")
        password_field.send_keys(password)
        print(f"{current_time()} Entered password. Waiting 1 second...")

        # Wait for 1 second
        time.sleep(1)

        # Find the login button and click it
        login_button = driver.find_element(By.CSS_SELECTOR, "[data-automationid='login-button']")
        login_button.click()
        print(f"{current_time()} Clicked login button. Waiting 5 seconds...")

        # Wait for 5 seconds
        time.sleep(5)

        # Check if the six-digit code field is available
        print(f"{current_time()} Checking for verification code request...")
        six_digit_code_field = driver.find_elements(By.CSS_SELECTOR, "[data-automationid='six-digit-code-field']")
        if six_digit_code_field:
            print(f"{current_time()} Verification code request found.")
            verification_code = get_user_input("Enter the six-digit verification code: ")
            six_digit_code_field[0].send_keys(verification_code)
            time.sleep(5)  # Wait for 5 seconds
            if driver.current_url != "https://auth.hulu.com/login":
                print(f"{current_time()} Login successful after verification!")
                break
            else:
                print(f"{current_time()} Login failed. Please check your credentials or verification code.")
        else:
            print(f"{current_time()} No verification code requested. Checking login success...")
            # Check if login was successful (webpage changed)
            if driver.current_url != "https://auth.hulu.com/login":
                print(f"{current_time()} Login successful!")
                break
            else:
                print(f"{current_time()} Login failed, requesting next steps...")
                retry_option = get_user_input_select("Login failed. Do you want to try again?", ["Yes, with same credentials", "Yes, with new credentials", "No, exit"])
                if retry_option == "No, exit":
                    print(f"{current_time()} Exiting...")
                    retry_option = None
                    chrome_quit(driver)
                    exit()
                    break
                elif retry_option == "Yes, with same credentials":
                    print(f"{current_time()} Retrying with same credentials...")
                    credential_flag = False
                    retry_option = None
                    chrome_quit(driver)
                    continue
                elif retry_option == "Yes, with new credentials":
                    print(f"{current_time()} Retrying with new credentials...")
                    credential_flag = True
                    retry_option = None
                    chrome_quit(driver)
                    continue
                else:
                    print(f"{current_time()} Invalid option. Please select a valid option.")

    print(f"{current_time()} Beginning Stream Link validation...")

    # Define the condition URLs (add more if needed)
    condition_urls = [
        "https://www.hulu.com/live-tv",
        "https://www.hulu.com/hub/home"
        # Add additional condition URLs here
    ]

    # Recursively search for .strmlink files
    print(f"{current_time()} Select a parent directory where Hulu Stream Links are...")
    base_directory = get_directory()
    print(f"{current_time()} Selected directory is {base_directory}")

    for root, _, files in os.walk(base_directory):
        for file in files:
            if file.endswith(".strmlnk"):
                file_path = os.path.join(root, file)
                with open(file_path, "r") as f:
                    url = f.read().strip()
                exception_test = False

                # Resolve the URL (you may need to adjust this based on your environment)
                try:
                    driver.get(url)
                    # Wait for 5 seconds
                    time.sleep(5)
                    print(f"{current_time()} {url} resolved to {driver.current_url}")
                    resolved_url = driver.current_url
                except Exception as e:
                    # Handle HTTP error (e.g., 404)
                    resolved_url = str(e)
                    exception_test = True

                if resolved_url in condition_urls or exception_test:
                    # Condition met: delete the file without prompting
                    os.remove(file_path)
                    print(f"{current_time()} Deleted {file_path}")
                else:
                    # Condition unmet: keep the file
                    print(f"{current_time()} Keeping {file_path}")

    # Remove empty subdirectories without prompting
    for root, dirs, _ in os.walk(base_directory, topdown=False):
        for dir_name in dirs:
            dir_path = os.path.join(root, dir_name)
            if not os.listdir(dir_path):
                os.rmdir(dir_path)
                print(f"{current_time()} Removed empty directory: {dir_path}")

    # Other Actions
    chrome_quit(driver)
    print(f"{current_time()} End of current commands.")
    exit()

if __name__ == "__main__":
    main()

The above script does Part 1: Cleanup. Based upon the user selected directory, it will go through all .strmlnk files and test the links against Hulu in a logged in session based upon the user's entered credentials. This gives correct results for all Movies and TV Shows. With the TV Shows, if a directory like a Season or the entire show is empty of Stream Links afterwards, it will delete that directory. While this is setup to work with popup boxes, if that is not an option on your system it will instead prompt you on the command line.

The only issue is that Hulu login usually fails in headless (non-visible) mode and occasionally when visible. Right now the headless option is commented out as I have not been able to trace-back any common reasoning aside from Hulu has a problem somewhere that I can't see.

HuluToDisney+.py

import os
import platform
import subprocess
import time
import datetime
import getpass

# Get current time for logging
def current_time():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ": "

# Install Selenium if not already installed
try:
    from selenium import webdriver
    from selenium.webdriver.chrome.service import Service
    from selenium.webdriver.common.by import By
    from selenium.webdriver.chrome.options import Options
    from selenium.webdriver.support.ui import WebDriverWait
    from selenium.webdriver.support import expected_conditions as EC
    from selenium.webdriver.common.action_chains import ActionChains
except ImportError:
    if os.name == "posix":  # Unix-like systems (including macOS)
        os.system("pip3 install selenium")
    else:   
        os.system("pip install selenium")

# Check if the OS allows popup boxes
try:
    import tkinter as tk
    from tkinter import Tk, simpledialog, IntVar, Radiobutton
except ImportError:
    # If popup boxes are not allowed, do not import tkinter
    pass

#Initialization

def get_chrome_executable_path():
    if 'CHROME_BIN' in os.environ:
        return os.environ['CHROME_BIN']

    chrome_executable_path = None

    print(f"{current_time()} System Identified as {platform.system()}. Setting Chrome Executable Path based on this...")

    if platform.system() == 'Linux':
        try:
            chrome_executable_path = subprocess.check_output(['which', 'chromium-browser']).decode().split('\n')[0]
        except subprocess.CalledProcessError:
            pass

        if not chrome_executable_path:
            try:
                chrome_executable_path = subprocess.check_output(['which', 'chromium']).decode().split('\n')[0]
                if not chrome_executable_path:
                    raise Exception('Chromium not found (which chromium)')
            except subprocess.CalledProcessError:
                pass
    elif platform.system() == 'Darwin':
        potential_paths = [
            '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
            '/Applications/Chromium.app/Contents/MacOS/Chromium',
            '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
        ]
        for path in potential_paths:
            if os.path.exists(path):
                chrome_executable_path = path
                break
    elif platform.system() == 'Windows':
        potential_paths = [
            r'C:\Program Files\Google\Chrome\Application\chrome.exe',
            r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
            os.path.join(os.getenv('USERPROFILE'), 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe'),
            os.path.join(os.getenv('USERPROFILE'), 'AppData', 'Local', 'Chromium', 'Application', 'chrome.exe'),
        ]
        for path in potential_paths:
            if os.path.exists(path):
                chrome_executable_path = path
                break
    else:
        raise Exception(f'Unsupported platform: {platform.system()}')

    print(f"{current_time()} Chrome Executable Path found at {chrome_executable_path}.")
    return chrome_executable_path

def create_chrome_driver():
    # Initialize Chrome driver
    chrome_options = webdriver.ChromeOptions()
    
    chrome_arguments = [
        '--headless',
        '--window-size=1920,1080',
        '--disable-web-security',
        '--no-first-run',
        '--disable-infobars',
        '--hide-crash-restore-bubble',
        '--disable-blink-features=AutomationControlled',
        '--hide-scrollbars',
        '--enable-automation',
        '--disable-extensions',
        '--disable-default-apps',
        '--disable-component-update',
        '--disable-component-extensions-with-background-pages',
        '--enable-blink-features=IdleDetection',
        '--no-sandbox',
        '--verbose'
    ]
    for arg in chrome_arguments:
        chrome_options.add_argument(arg)
    
    # Set the path to the Chrome executable (modify this based on your system)
    chrome_options.binary_location = get_chrome_executable_path()  # Use the previously defined function

    # Initialize the Chrome driver
    driver = webdriver.Chrome(options=chrome_options)
    
    return driver

# Exit Chrome and kill all Chrome processes
def chrome_quit(driver):
    print(f"{current_time()} Closing Chrome session...")
    driver.quit()
    print(f"{current_time()} Chrome session closed.")

# Fill in field
def get_user_input(prompt):
    if os.name == "posix":  # Unix-like systems (including macOS)
        # Use command line input
        while True:
            user_input = input(prompt)
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")
    else:
        # Use pop-up window
        while True:
            root = Tk()
            root.withdraw()  # Hide the main window
            user_input = simpledialog.askstring("Input", prompt)
            root.destroy()  # Close the pop-up window
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")

# Fill in field (masked)
def get_user_input_masked(prompt):
    if os.name == "posix": # Unix-like systems (including macOS)
        # Use command line input
        while True:
            user_input = getpass.getpass(prompt)
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")
    else:
        # Use pop-up window
        while True:
            root = Tk()
            root.withdraw()  # Hide the main window
            user_input = simpledialog.askstring("Input", prompt, show="*")
            root.destroy()  # Close the pop-up window
            if user_input:
                return user_input
            else:
                print(f"{current_time()} Input cannot be blank. Please try again.")

# Select an option from list
def get_user_input_select(prompt, options):
    if os.name == "posix": # Unix-like systems (including macOS)
        # Use command line input
        print(prompt)
        for i, option in enumerate(options):
            print(f"{i + 1}. {option}")
        while True:
            try:
                selected_option = int(input("Select an option: "))
                if 1 <= selected_option <= len(options):
                    return options[selected_option - 1]
                else:
                    print("Invalid option. Please try again.")
            except ValueError:
                print("Invalid input. Please enter a number.")
    else:
        # Use pop-up window
        root = tk.Tk()
        root.withdraw()

        # Create a variable to store the selected option
        selected_option = None

        # Create a dialog box with buttons for each option
        dialog = tk.Toplevel(root)
        dialog.title("Select an option")
        dialog.geometry("300x150")

        # Add the prompt to the dialog
        tk.Label(dialog, text=prompt).pack()

        def select_option(option):
            nonlocal selected_option
            selected_option = option
            dialog.destroy()

        for option in options:
            tk.Button(dialog, text=option, command=lambda o=option: select_option(o)).pack()

        # Wait for the user to select an option
        dialog.wait_window()

        # Return the selected option
        return selected_option

# Pick a directory
def get_directory():
    while True:
        if os.name == "posix":  # Unix-like systems (including macOS)
            directory_path = input("Enter a directory path: ")
        else:
            try:
                import tkinter as tk
                from tkinter import filedialog
                root = tk.Tk()
                root.withdraw()  # Hide the main window
                directory_path = filedialog.askdirectory(title="Select a base directory")
            except ImportError:
                directory_path = input("Enter a directory path: ")

        # Validate if a value was entered
        if directory_path:
            break
        else:
            print(f"{current_time()} Error: Please provide a valid directory path.")

    return directory_path

def main():
    username_flag = True
    password_flag = True

    # Define the URLs to test is a username was successful (add more if needed)
    username_urls = [
        "https://www.disneyplus.com/identity/login/enter-password",
        "https://www.disneyplus.com/identity/login/enter-passcode" #,
        # Add additional condition URLs here
    ]

    while True:

        # Initialize the Chrome driver
        driver = create_chrome_driver()
        print(f"{current_time()} Chrome session is running in the background.")

        # Navigate to the Disney+ login page
        driver.get("https://www.disneyplus.com/identity/login")
        print(f"{current_time()} Navigated to Disney+ login page.")

        # Wait for the page to be fully rendered (e.g., wait for the login form to appear)
        print(f"{current_time()} Waiting for page to fully render...")
        wait = WebDriverWait(driver, 10)
        username_field = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "[id='email']")))
        print(f"{current_time()} Page rendered, moving on after 1 second...")

        # Wait for 1 second
        time.sleep(1)

        if username_flag:
            # Prompt user for username (email)
            print(f"{current_time()} Prompting for Disney+ username.")
            username = get_user_input("Enter your Disney+ username (email address): ")
        
        else:
            print(f"{current_time()} Reusing previous Disney+ username.")

        # Find the username field and enter the username
        username_field = driver.find_element(By.CSS_SELECTOR, "[id='email']")
        username_field.send_keys(username)
        print(f"{current_time()} Entered username. Waiting 1 second...")

        # Wait for 1 second
        time.sleep(1)

        # Find the continue button and click it
        continue_button = driver.find_element(By.CSS_SELECTOR, "[data-testid='continue-btn']")
        continue_button.click()
        print(f"{current_time()} Clicked continue button. Waiting 5 seconds...")

        # Wait for 5 seconds
        time.sleep(5)

        # Checking for Username success
        print(f"{current_time()} Checking for username success...")
        resolved_url = driver.current_url
        if resolved_url not in username_urls:
            print(f"{current_time()} Login (Username) failed, requesting next steps...")
            retry_option = get_user_input_select("Login (Username) failed. Do you want to try again?", ["Yes, with same credentials", "Yes, with new credentials", "No, exit"])
            if retry_option == "No, exit":
                print(f"{current_time()} Exiting...")
                retry_option = None
                chrome_quit(driver)
                exit()
            elif retry_option == "Yes, with same credentials":
                print(f"{current_time()} Retrying with same credentials...")
                username_flag = False
                password_flag = False
                retry_option = None
                chrome_quit(driver)
                continue
            elif retry_option == "Yes, with new credentials":
                print(f"{current_time()} Retrying with new credentials...")
                username_flag = True
                password_flag = True
                retry_option = None
                chrome_quit(driver)
                continue
            else:
                print(f"{current_time()} Invalid option. Please select a valid option.")
        else:
            print(f"{current_time()} Username successfully entered. Continuing login process...")

        # Checking for Password or Verification request
        print(f"{current_time()} Checking for Password or Verification request...")
        password_or_verification_url = driver.current_url
        if driver.current_url == "https://www.disneyplus.com/identity/login/enter-password":
            print(f"{current_time()} Password request found.")
            if password_flag:
                # Prompt user for password (masked input)
                print(f"{current_time()} Prompting for Disney+ password.")
                password = get_user_input_masked("Enter your Disney+ password: ")
            else:
                print(f"{current_time()} Reusing previous Disney+ password.")

            # Find the password field and enter the password
            password_field = driver.find_element(By.CSS_SELECTOR, "[id='password']")
            password_field.send_keys(password)
            print(f"{current_time()} Entered password. Waiting 1 second...")

            # Wait for 1 second
            time.sleep(1)

            # Find the login button and click it
            login_button = driver.find_element(By.CSS_SELECTOR, "[type='submit']")
            login_button.click()
            print(f"{current_time()} Clicked password button. Waiting 5 seconds...")

            # Wait for 5 seconds
            time.sleep(5)

        elif driver.current_url == "https://www.disneyplus.com/identity/login/enter-passcode":
            print(f"{current_time()} Verification code request found.")
            verification_code = get_user_input("Enter the six-digit verification code: ")
            time.sleep(1)
            actions = ActionChains(driver)
            actions.send_keys(verification_code).perform()

            # Wait for 1 second
            time.sleep(1)

            # Find the continue button and click it
            verification_button = driver.find_element(By.CSS_SELECTOR, "[data-testid='continue-btn']")
            verification_button.click()
            print(f"{current_time()} Clicked verification button. Waiting 5 seconds...")

            # Wait for 5 seconds
            time.sleep(5)

        else:
            print(f"{current_time()} Neither Password nor Verification Requested.")

        # Check if login was successful (webpage changed)
        print(f"{current_time()} Checking login success...")
        if driver.current_url != password_or_verification_url:
            print(f"{current_time()} Login successful!")
            break
        else:
            print(f"{current_time()} Login failed, requesting next steps...")
            retry_option = get_user_input_select("Login failed. Do you want to try again?", ["Yes, with same credentials", "Yes, with new credentials", "Yes, with same username and new password", "No, exit"])
            if retry_option == "No, exit":
                print(f"{current_time()} Exiting...")
                retry_option = None
                chrome_quit(driver)
                exit()
            elif retry_option == "Yes, with same credentials":
                print(f"{current_time()} Retrying with same credentials...")
                username_flag = False
                password_flag = False
                retry_option = None
                chrome_quit(driver)
                continue
            elif retry_option == "Yes, with new credentials":
                print(f"{current_time()} Retrying with new credentials...")
                username_flag = True
                password_flag = True
                retry_option = None
                chrome_quit(driver)
                continue
            elif retry_option == "Yes, with same username and new password":
                print(f"{current_time()} Retrying with same username and new password...")
                username_flag = False
                password_flag = True
                retry_option = None
                chrome_quit(driver)
                continue
            else:
                print(f"{current_time()} Invalid option. Please select a valid option.")
    
    # Define the successful URLs (add more if needed)
    successful_urls = [
        "https://www.disneyplus.com/home",
        "https://www.disneyplus.com/select-profile" #,
        # Add additional condition URLs here
    ]

    while True:
        if driver.current_url not in successful_urls:
            print(f"{current_time()} Disney+ is having a loading issue. Trying again in 5 seconds...")
            time.sleep(5) # Wait for 5 seconds
            continue
        else:
            print(f"{current_time()} Disney+ has moved to next screen.")
            break

    # Check to see if Profile selection required
    print(f"{current_time()} Checking for Profile selection request after waiting 5 seconds...")
    time.sleep(5) # Wait for 5 seconds

    if driver.current_url ==  "https://www.disneyplus.com/select-profile":
        print(f"{current_time()} Profile selection request found. Clicking on first available...")
        profile_button = driver.find_element(By.CSS_SELECTOR, "[data-testid='selected-avatar-image']")
        profile_button.click()
        print(f"{current_time()} Clicked on a Profile. Waiting 5 seconds...")
        time.sleep(5)  # Wait for 5 seconds
    else:
        print(f"{current_time()} No Profile selection found. Moving on...")

    # Pick directory for .strmlnk files
    print(f"{current_time()} Select a parent directory where Hulu Stream Links are...")
    base_directory = get_directory()
    print(f"{current_time()} Selected directory is {base_directory}")

    print(f"{current_time()} Beginning Stream Link check...")

    # Define the condition URLs (add more if needed)
    condition_urls = [
        "https://www.disneyplus.com/home" #,
        # Add additional condition URLs here
    ]

    # Recursively check each .strmlnk file
    for root, _, files in os.walk(base_directory):
        for file in files:
            if file.endswith(".strmlnk"):
                file_path = os.path.join(root, file)
                with open(file_path, "r") as f:
                    url = f.read().strip()
                exception_test = False

                 # Replace "hulu.com/watch" with "disneyplus.com/play" in the URL
                modified_url = url.replace("hulu.com/watch", "disneyplus.com/play")

                # Resolve the URL
                try:
                    driver.get(modified_url)
                    # Wait for 5 seconds
                    time.sleep(5)
                    print(f"{current_time()} {modified_url} resolved to {driver.current_url}")
                    resolved_url = driver.current_url
                except Exception as e:
                    # Handle HTTP error (e.g., 404)
                    resolved_url = str(e)
                    exception_test = True

                if resolved_url in condition_urls or exception_test:
                    # Condition met: do not modify file
                    print(f"{current_time()} Maintained Hulu Stream Link for {file_path}")
                else:
                    # Condition unmet: modify file
                    with open(file_path, "w") as f:
                        f.write(modified_url)
                    print(f"{current_time()} Changed Stream Link from Hulu to Disney+ for {file_path}")

    # Other Actions
    chrome_quit(driver)
    print(f"{current_time()} End of current commands.")
    quit()

if __name__ == "__main__":
    main()

The above script does Part 2: Conversion. Based upon the the user selected directory, it will go through all the .strmlnk files and test the links against Disney+ by changing "hulu.com/watch" to "disneyplus.com/play" in a logged in session based upon the user's entered credentials. This gives the correct results for all Movies and TV Shows. Unlike the cleanup process above, this works in headless (non-visible) mode, so most of this is just happening in the background.

EDIT NOTE: Disney+ changed the login process after the original post of this. It is now updated to deal with that and a variety of other issues that may come up. As such, the directions below may not be exact as to what you'd see.


Running the process, you can see it launching Chrome and starting the login procedure:

2024-04-03 16:26:13.353269:  System Identified as Windows. Setting Chrome Executable Path based on this...
2024-04-03 16:26:13.353269:  Chrome Executable Path found at C:\Program Files\Google\Chrome\Application\chrome.exe.
DevTools listening on ws://127.0.0.1:58518/devtools/browser/2c6bfc5b-5794-4980-8c33-7b15b78145d2
2024-04-03 16:26:14.777033:  Chrome session is running in the background.
2024-04-03 16:26:16.056738:  Navigated to Disney+ login page.
2024-04-03 16:26:16.057739:  Waiting for page to fully render...
2024-04-03 16:26:18.145867:  Page rendered, moving on after 1 second...
[0403/162619.108:INFO:CONSOLE(0)] "Third-party cookie will be blocked. Learn more in the Issues tab.", source: https://www.disneyplus.com/identity/login/enter-email (0)
2024-04-03 16:26:19.158319:  Prompting for Disney+ username.

The user inputs their username. For systems that do not support popups, all inputs are done on the command line.

JP_ScreenShot_2024_04_03_15_35_39

The program then uses the input and moves on to various tests and the next steps.

2024-04-03 16:26:24.851620:  Entered username. Waiting 1 second...
2024-04-03 16:26:25.916773:  Clicked continue button. Waiting 5 seconds...
[0403/162626.048:INFO:CONSOLE(0)] "Third-party cookie will be blocked. Learn more in the Issues tab.", source: https://www.disneyplus.com/identity/login/enter-email (0)
2024-04-03 16:26:30.918192:  Checking for username success...
2024-04-03 16:26:30.921209:  Username successfully entered. Continuing login process...
2024-04-03 16:26:30.921209:  Prompting for Disney+ password.

Eventually it will reach the point needed for a password or (more likely) a verification code.

JP_ScreenShot_2024_04_03_15_36_06

And again, there are many checks and things done once that is entered.

2024-04-03 16:26:35.534005:  Entered password. Waiting 1 second...
2024-04-03 16:26:36.588367:  Clicked login button. Waiting 5 seconds...
[0403/162637.077:INFO:CONSOLE(0)] "Third-party cookie will be blocked. Learn more in the Issues tab.", source: https://www.disneyplus.com/onboarding? (0)
2024-04-03 16:26:41.589052:  Checking for verification code request...
2024-04-03 16:26:41.600271:  No verification code requested. Checking login success...
2024-04-03 16:26:41.605086:  Login successful!
2024-04-03 16:26:41.605086:  Checking for Profile selection request after waiting 5 seconds...
2024-04-03 16:26:46.610816:  Profile selection request found. Clicking on first available...
2024-04-03 16:26:46.671775:  Clicked on a Profile. Waiting 5 seconds...
2024-04-03 16:26:51.673849:  Select a parent directory where Hulu Stream Links are...

Finally, the user selects the parent directory to all of the Hulu Stream Link files.

At this point, the real fun begins! It will go through every Stream Link and see if it exists on Disney+. If it does, the Stream Link is updated. If it isn't, the Stream Link is left alone.

2024-04-03 16:26:55.442398:  Selected directory is C:/Temp/Test/HtoD
2024-04-03 16:26:55.443407:  Beginning Stream Link check...

2024-04-03 16:27:02.425655:  https://www.disneyplus.com/play/c4bda460-f3a1-4f91-9b2f-2eac8b82fafb resolved to https://www.disneyplus.com/play/c4bda460-f3a1-4f91-9b2f-2eac8b82fafb
2024-04-03 16:27:02.734570:  Changed Stream Link from Hulu to Disney+ for C:/Temp/Test/HtoD\Becoming Bond (2017).strmlnk

2024-04-03 16:27:08.219809:  https://www.disneyplus.com/play/d2ca67b9-0e07-4448-bede-115daf26cf0d resolved to https://www.disneyplus.com/home
2024-04-03 16:27:08.219809:  Maintained Hulu Stream Link for C:/Temp/Test/HtoD\Huluween Dragstravaganza (2022).strmlnk

2024-04-03 16:27:13.463093:  https://www.disneyplus.com/play/025e53a2-4539-4784-a42a-8b295b134408 resolved to https://www.disneyplus.com/home
2024-04-03 16:27:13.470102:  Maintained Hulu Stream Link for C:/Temp/Test/HtoD\I Kill Giants (2017).strmlnk

2024-04-03 16:27:18.850521:  https://www.disneyplus.com/play/df471734-b23c-4d66-9124-960173810541 resolved to https://www.disneyplus.com/play/df471734-b23c-4d66-9124-960173810541
2024-04-03 16:27:18.939632:  Changed Stream Link from Hulu to Disney+ for C:/Temp/Test/HtoD\Scarface (1983).strmlnk

In this example, you can see the files as they were before, and what they looked like after the process was run:

Before...
JP_ScreenShot_2024_04_03_15_45_57

After...
JP_ScreenShot_2024_04_03_15_46_11

If you have a non-Hulu Stream Link in the directory, you may see something like this:

2024-04-03 16:51:13.075436:  https://www.kanopy.com/video/13448336 resolved to https://www.kanopy.com/en/product/13448336
2024-04-03 16:51:13.086212:  Changed Stream Link from Hulu to Disney+ for C:/Temp/Test/HtoD\Raiders! The Story of the Greatest Fan Film Ever Made (2015).strmlnk

However, although it updates the file, it only puts the same link back in:

I've decided it is too minor of an issue to address as it causes no harm.

After that, it is just cleanup and closeout.

2024-04-03 16:27:18.939632:  Closing Chrome session...
2024-04-03 16:27:21.051067:  Chrome session closed.
2024-04-03 16:27:21.051067:  End of current commands.

Enjoy!

1 Like

Just a little post-action notes and highlights:

  • After ~30 hours of processing, things worked well with minimal issues
  • In the cleanup, a handful of shows left an episode behind
  • During the conversion, only 1 movie out of 37 converted that shouldn't have
  • During the conversion, only a handful of episodes did/did not convert when they were supposed to (among ~7000 files)
  • These programs ran light, taking almost no memory or bandwidth

In order to assist with identifying where the issues might be, I developed one last script:

PostDisney+ConversionCheck.py

import os
from pathlib import Path
import datetime

# Get current date/time for logging
def current_time():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ": "

# Get a directory path from the user using a file dialog or command line.
def get_directory():
    while True:
        if os.name == "posix":  # Unix-like systems (including macOS)
            directory_path = input("Enter a directory path: ")
        else:
            try:
                import tkinter as tk
                from tkinter import filedialog
                root = tk.Tk()
                root.withdraw()  # Hide the main window
                directory_path = filedialog.askdirectory(title="Select a base directory")
            except ImportError:
                directory_path = input("Enter a directory path: ")

        # Validate if a value was entered
        if directory_path:
            break
        else:
            print(f"{current_time()} Error: Please provide a valid directory path.")

    return directory_path

# Get the number of files in the current folder (excluding subfolders)
def get_file_count(folder_path):
    file_count = len([file for file in Path(folder_path).rglob("*") if file.is_file()])
    return file_count

# Get the most recent and oldest dates of any file in the folder (based on Year, Month, and Day only)
def get_recent_and_oldest_dates(folder_path):
    all_file_dates = [file.stat().st_mtime for file in Path(folder_path).rglob("*") if file.is_file()]
    recent_date = datetime.datetime.fromtimestamp(max(all_file_dates)).strftime('%Y-%m-%d')
    oldest_date = datetime.datetime.fromtimestamp(min(all_file_dates)).strftime('%Y-%m-%d')
    return recent_date, oldest_date

# Create a table to display the results
def display_results(results):
    # Determine column widths based on the length of values plus 5
    col_widths = {
        'Difference': max(len('Difference') + 5, max(len(result['Difference']) + 5 for result in results)),
        'FolderName': max(len('Folder Name') + 5, max(len(result['FolderName']) + 5 for result in results)),
        'FileCount': max(len('File Count') + 5, max(len(str(result['FileCount'])) + 5 for result in results)),
        'RecentDate': max(len('Recent Date') + 5, max(len(result['RecentDate']) + 5 for result in results)),
        'OldestDate': max(len('Oldest Date') + 5, max(len(result['OldestDate']) + 5 for result in results))
    }

    # Display the results in a table format
    print(f"{'Difference':<{col_widths['Difference']}} {'Folder Name':<{col_widths['FolderName']}} {'File Count':<{col_widths['FileCount']}} {'Recent Date':<{col_widths['RecentDate']}} {'Oldest Date':<{col_widths['OldestDate']}}")
    print("-" * sum(col_widths.values()))
    for result in results:
        print(f"{result['Difference']:<{col_widths['Difference']}} {result['FolderName']:<{col_widths['FolderName']}} {result['FileCount']:<{col_widths['FileCount']}} {result['RecentDate']:<{col_widths['RecentDate']}} {result['OldestDate']:<{col_widths['OldestDate']}}")

# Main actions
def main():
    # Select the directory to run the process on
    current_directory = get_directory()
    print(f"{current_time()} Selected directory: {current_directory}")

    # Get all first-level subfolders in the current directory (excluding subfolders)
    subfolders = [folder for folder in os.listdir(current_directory) if os.path.isdir(os.path.join(current_directory, folder))]

    # Create a list to store the results
    results = []

    # Iterate over each first-level subfolder
    for folder in subfolders:
        folder_path = os.path.join(current_directory, folder)
        file_count = get_file_count(folder_path)
        recent_date, oldest_date = get_recent_and_oldest_dates(folder_path)

        # Determine if RecentDate is different from OldestDate
        date_difference = 'x' if recent_date != oldest_date else ''

        # Create a custom dictionary with the folder name, file count, recent date, oldest date, and 'x' if RecentDate != OldestDate
        result_dict = {
            'Difference': date_difference,
            'FolderName': folder,
            'FileCount': file_count,
            'RecentDate': recent_date,
            'OldestDate': oldest_date
        }

        # Add the result dictionary to the list
        results.append(result_dict)

    # Display the results in a table format
    display_results(results)

    # Other Actions
    print(f"{current_time()} End of current commands.")
    exit()

if __name__ == "__main__":
    main()

This will display a table that looks something like this:

I used this to identify what had changed among the shows and if everything had been updated or not. This brings up some of the statistical information:

  • Even if a show is on Disney+, it does not mean all the episodes are there. For instance, with Fargo, only Season 5 is on Disney+ even though all seasons are on Hulu. With others like Buffy the Vampire Slayer, every single episode except for one is on Disney+.
  • For some strange reason, Disney+ had chosen not to use the same content ID as Hulu on various occasions. This was actually my largest and longest manual fix after the fact: finding the directories with a difference, hunting down the individual files, and getting the Steam Link on Disney+ directly to do a replacement in the file.
  • In my testing based upon the content I have as Stream Links, between around 13% or so of the programs are not available on Disney+ despite being available on Hulu.
  • The most likely category to not be on Disney+ in general is International Content, and even more specifically Anime and British Home Shows. But again, not everything in these categories, not even from the same providers! It's a mixed bag all over the place.