[RELEASE] Stream Link Manager for Channels

Stream Link Manager for Channels

In Channels DVR, users have the ability to add "Stream Links" and "Stream Files" as local content. These Stream Links/Files appear as normal Movies, TV Shows, and Videos next to recorded and other content. While Stream Files act like regular local media and directly play in the Channels app or admin web page, Stream Links do not. Instead, clicking on one of these launches the appropriate app or web page and plays the content there. In order to do either, the process consists of creating .strmlnk or .strm files, putting them in the appropriate location, and running updates in the Channels DVR admin interface to get the content to appear. As can be imagined, the activity around creation and maintenance is incredibly manual and cumbersome.

Enter Stream Link Manager for Channels!

Stream Link Manager for Channels is a background service that sets up a web-based graphical user interface (GUI) for interaction. In the GUI, users can search for any Movie or TV Show and bookmark it. If it cannot be found, manual additions are allowed. Assuming a program is found, for "Stream Links", the software will parse through a user-derived list of Streaming Services (i.e., Disney+, Hulu, Netflix, Hoopla, Kanopy, etc...) in priority order—including setting a preferred service for a particular Movie or Episode as an overarching setting—in order to determine the appropriate link. There is also the ability to input user-derived links, especially when dealing with "Stream Files". After this, the necessary folders and files will be created, along with completing all other administrative tasks. Should a bookmark move from one Streaming Service to another or the user does a manual adjustment, Stream Link Manager for Channels will automatically update everywhere that is required. But this is just the beginning of its capabilities!

Read more and follow the installation / upgrade / usage instructions here:

https://github.com/babsonnexus/stream-link-manager-for-channels

Watch the video here:

  • 0:00 Channels DVR Overview | Recordings, Personal Content, Stream Files
  • 1:07 Stream Link Demo
  • 2:23 Stream Links Backend
  • 3:25 Stream Link Manager
  • 6:35 User Experience
  • 7:18 How to Get | Disclaimers
  • 7:50 Summary and Wrap-up

Also available as an add-on:


Please note that the first 48 posts in this thread relate to the development process and are now outdated.

Original-ish post

After starting to play around with getting a clean conversion from Hulu to Disney+ as part of the merger of the apps, I began to kick around the idea of something much bigger: a Stream Link Manager. The idea is this:

  • :white_check_mark: Locally run program that has a :white_check_mark: web-based front-end interface (maybe :white_check_mark: Docker, maybe :white_check_mark: executable?)
  • :white_check_mark: In the interface, user selects which streaming services they have access to and puts them into a preference order
  • :white_check_mark: In the interface, user searches for a program (movie or TV Show)
  • :white_check_mark: User bookmarks program for consumption
  • :white_check_mark: Interface uses the API of an existing management system, preferably :white_check_mark: JustWatch
  • :white_check_mark: JustWatch returns the URLs of the programs
  • :white_check_mark: Interface creates and manages .strmlnk files in the appropriate Channels directory. :white_check_mark: Once per day or hour or some other user selected interval, :white_check_mark: the Interface will check the existing management system to see if the program is available on one of the user defined streaming services. :white_check_mark: If it is on multiple, it will go with the one with the highest preference order.
    -- :white_check_mark: If the program changes streaming services, the .strmlnk will automatically update
    -- :white_check_mark: If the program is no longer available on any streaming service the user has selected, it will delete the .strmlnk file
  • :white_check_mark: Channels already scans for media updates on a user defined schedule. However, will need to automate running an automatic prune (:white_check_mark: user choice if desired, just in case)
  • :white_check_mark: If a user deletes a program in Channels, an API call will have to check for this and remove from the program from the Interface so the .strmlnk is not recreated.

:white_check_mark: See: High Level Flow Diagram below...

So, fairly complicated in the solution acting as a middleman between two disparate systems. As such, I've decided to approach this piecemeal, getting bits working at a time. Below I will place each stand-alone component so they can be run individually without the interface in general. The current components consist of:

Special thanks to Alpha/Beta testers (in no particular order), who, without their help, this would not be possible: @jasonmcroy, @mgantt87, @Fofer, @bnhf, @mjitkop, and @Jean0987654321

5 Likes

This Python script will allow a user to create all the necessary .strmlnk files for a TV Show. It does not add the URL content to the files; it just creates all the directories and necessary files.

Name this something like below. It doesn't matter where you put it.

ProtoType_Stream_Link_File_Creator.py

import os
import sys
import datetime

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

def current_time():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ": "

def get_directory():
    """Get a directory path from the user using a file dialog or command line."""
    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:
            print("Error: tkinter is not available. Please enter the directory path manually.")
            directory_path = input("Enter a directory path: ")

    return directory_path

def get_user_input(prompt):
    """Get user input from a popup box or command line."""
    if os.name == "posix":  # Unix-like systems (including macOS)
        user_input = input(prompt)
    else:
        try:
            import tkinter as tk
            from tkinter import simpledialog, messagebox
            root = tk.Tk()
            root.withdraw()  # Hide the main window
            user_input = simpledialog.askstring("Input", prompt)
            root.destroy()  # Close the pop-up window
        except ImportError:
            print("Error: tkinter is not available. Please enter the input manually.")
            user_input = input(prompt)

    return user_input.strip()  # Remove leading/trailing spaces

def get_radio_selection(prompt, options):
    """Get user selection from radio buttons or command line."""
    if os.name == "posix":  # Unix-like systems (including macOS)
        print(prompt)
        for i, option in enumerate(options, start=1):
            print(f"{i}. {option}")
        while True:
            try:
                choice = int(input("Enter the number corresponding to your choice: "))
                if 1 <= choice <= len(options):
                    return options[choice - 1]
                else:
                    print("Invalid choice. Please try again.")
            except ValueError:
                print("Invalid input. Please enter a valid number.")
    else:
        try:
            import tkinter as tk
            root = tk.Tk()
            root.withdraw()  # Hide the main window

            # Create a dialog box with radio buttons for each option
            dialog = tk.Toplevel(root)
            dialog.title("Select a Prefix Option")

            # Calculate the height of the dialog based on the number of options
            dialog_height = 150 + len(options) * 30
            dialog.geometry(f"300x{dialog_height}")

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

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

            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
        except ImportError:
            print("Error: tkinter is not available. Please enter the input manually.")
            return input(prompt)

def create_directory(directory_path):
    """Create a directory if it doesn't exist."""
    if not os.path.exists(directory_path):
        os.makedirs(directory_path)

def create_season_folders(base_directory, starting_season, ending_season):
    """Create season folders with proper naming."""
    for season_number in range(starting_season, ending_season + 1):
        season_folder_name = f"Season {season_number:02}"
        season_folder_path = os.path.join(base_directory, season_folder_name)
        create_directory(season_folder_path)

def create_episode_files(season_folder, episode_count, prefix):
    """Create episode files within a season folder."""
    for episode_number in range(1, episode_count + 1):
        episode_name = f"S{season_folder[-2:]}E{episode_number:02}"
        if prefix != "None":
            episode_name = f"{prefix} {episode_name}"
        episode_name += ".strmlnk"
        episode_file_path = os.path.join(season_folder, episode_name)
        if not os.path.exists(episode_file_path):
            open(episode_file_path, 'w').close()

def main():
    base_directory = get_directory()
    series_name_year = get_user_input("Enter the Series Name (Year): ")
    prefix_options = ["None", "(DUB)", "(SUB)"]
    selected_prefix = get_radio_selection("Select a Prefix Option:", prefix_options)
    starting_season = int(get_user_input("Enter the Starting Season Number: "))
    ending_season = int(get_user_input("Enter the Ending Season Number: "))

    create_directory(os.path.join(base_directory, series_name_year))

    create_season_folders(os.path.join(base_directory, series_name_year), starting_season, ending_season)

    for season_number in range(starting_season, ending_season + 1):
        season_folder_name = f"Season {season_number:02}"
        season_folder_path = os.path.join(base_directory, series_name_year, season_folder_name)
        episode_count = int(get_user_input(f"Number of Episodes for {season_folder_name}: "))
        create_episode_files(season_folder_path, episode_count, selected_prefix)

    print(f"{current_time()} Directory structure and files created successfully!")

if __name__ == "__main__":
    main()

Running the code, the user will be prompted to select a base directory where they want the program to go. This should be somewhere under the Imports\TV directory, either directly or a management folder.

After selecting that, the user then enters the program name, preferably in the format PROGRAM (YEAR). If they happen to put in some extra spaces at the beginning or the end, this will automatically clear them out, so no issue.

JP_ScreenShot_2024_04_01_11_22_28

Normally, the format of the resulting Stream Link file will be something like "S01E01.strmlnk". However, there is an option to add a Prefix. In this version of the code, the user can select between having nothing, "(DUB)" or "(SUB)", which I find useful for Hulu/Disney+ that separates programs this way instead of having you select a language in the interface.

JP_ScreenShot_2024_04_01_11_22_42

At this point, the user enters the starting and ending Season numbers. This will create folders for those Seasons and everything in between.

JP_ScreenShot_2024_04_01_11_23_05

JP_ScreenShot_2024_04_01_11_23_22

They will then be prompted to enter in the number of Episode per Season.

JP_ScreenShot_2024_04_01_11_23_37

JP_ScreenShot_2024_04_01_11_23_54

That is all the information that is needed. A folder will be created with the entered program name and separate ones for all the Seasons. Note that if the Season or Episode number is a single digit, it will create it with a leading zero. If the Season or Episode number already exists, it does not recreate it, but instead just moves on.

JP_ScreenShot_2024_04_01_11_24_48

At this point, all the files exist and the user can edit them to put in the appropriate URL and do the rest of the normal process.

JP_ScreenShot_2024_04_01_11_25_09

Also, in case you are on a system that does not allow popup boxes, you will go through the same process on a command line intead:

image


Please be advised that I have only tested this in Windows and WSL (Ubuntu). I would appreciate if someone with a Mac or pure Linux setup would test and see if you have any additional issues so that I can address them.

This is a cool project. I'm using Windows too.

I'm not sure I understand the use of the prefix, DUB or SUB.

Is DUB for "dubbed," meaning originally in a different language other than English?

Is SUB for "subtitled", meaning the episode is in a foreign language and contains forced English subtitles on the screen?

Yes

2 Likes

Spot on. Think of it this way: Hulu/Disney+ has many foreign language programs. Season 1 may be available both as dubbed and subtitled, but Season 2 is only subtitled. But then three months later the dubbed version of Season 2 becomes available. Thus originally I would create the show with all episodes, including the Subtitled version for Season 2. However, I would then replace them with the dubbed version, which is my personal preference. So I use the Prefix to tell me which version my Stream Link is pointing to.

1 Like

If the script will support command line arguments, I can give it the "OliveTin treatment" and add it as an OliveTin Action. How's that sound? :slight_smile:

1 Like

Well, this is like Day 1 pre-Alpha, so I wouldn't recommend jumping the gun just yet!

No worries on that. Mostly I just wanted to check that you're planning to support command line arguments in addition to pop-up boxes.

1 Like

Well, the end goal is to have a web interface with minimal human intervention, so the final product wouldn't even use popups and things to enter like this. My intention for the interim, though, is to release various stand-alone components as I work through them. File creation is one of the more annoying aspects of Stream Links, so I thought it would make a good singular piece to release.

That all said, I have modified the code above so that it will use popup boxes if it can, otherwise it will use command lines, so it's all good now:

If I’m understanding the end goal of this project correctly - I am very excited for it to get off the ground.

Will it work like:

  • Open program
  • Select streaming service
  • Search (eg “Marvel”)
  • Tick movies you want to add
  • Automatically gets added to channels server

?

What services are you hoping to have on this?

2 Likes

I thought I'd give this a whirl in OliveTin, so you can see what it would be like. Since you're not supporting passing arguments in a single command in your Python script, and Bash is super-easy for this kind of thing, I also made a couple of changes to see if they meet your goals.

I set it up to match the series name, and first airing year, with a query to the TVMaze API. This will pull the JSON data for the all season and episode data for that series, and create files in the same location you demo'd.

Here's what it looks like:

screenshot-htpc6-2024.04.02-19_01_26

Here's the script:

#!/bin/bash

set -x

# Global variables
dvr="$1"
channelsHost=$(echo $dvr | awk -F: '{print $1}')
channelsPort=$(echo $dvr | awk -F: '{print $2}')
dvrShare=/mnt/$channelsHost-$channelsPort/Imports/TV/Hulu
showName=$(echo "$2" | sed -e 's/\b\(.\)/\u\1/g' -e 's/ /%20/g')
showYear="\"$3\""
episodePrefix="$4"
  [[ "$episodePrefix" == "none" ]] && episodePrefix=""

# TVMaze API calls and post jq variable format changes
showID=$(curl -s "http://api.tvmaze.com/search/shows?q=$showName" | jq '.[] | select(.show.premiered | startswith('$showYear')).show.id')
episodeList=$(curl -s "http://api.tvmaze.com/shows/$showID/episodes" | jq -r '.[] | "S\((if .season < 10 then "0" else "" end) + (.season | tostring))E\((if .number < 10 then "0" else "" end) + (.number | tostring))"')
showName=$(echo "$showName" | sed 's/%20/ /g')
showYear=$(echo "$showYear" | sed 's/"//g')
[[ -n $episodePrefix ]] && episodePrefix="($episodePrefix) "

# Episode list to array and streamlink file creation
mapfile -t episodeArray <<< "$episodeList"
for episode in "${episodeArray[@]}"; do
  seasonNumber="Season ${episode:1:2}"
  mkdir -p "$dvrShare/$showName ($showYear)/$seasonNumber"
  touch "$dvrShare/$showName ($showYear)/$seasonNumber/$episodePrefix$episode.strmlnk" \
    && echo Confirmed or added: "$dvrShare/$showName ($showYear)/$seasonNumber/$episodePrefix$episode.strmlnk"
done

This will run on any system with OliveTin properly installed, from anywhere on one's LAN or Tailnet. Output can be directed to the DVR_SHARE for however many Channels DVRs are configured in OliveTin. Mostly a proof of concept for you at this point, let me know what you think. :slight_smile:

More like this:

  1. Open Program

  2. Provider Controls
    -- Select Streaming Services
    -- Set priority order for Streaming Services
    -- Should have the ability to have all of them (or whatever the backend management service like JustWatch has available).

  3. Pick Programs and Availability
    -- Search (e.g., "Hot Tub Time Machine")
    -- Tick program you want to add (or, in the case of TV shows, pick the season [All, specific, only new])
    -- Also have the ability to create a manual entry in case the underlying database does not have a record.

  4. Stream Link Generator
    -- IF the Program is available on a selected Streaming Service, create the .strmlnk file ("Hot Tub Time Machine (2010).strmlnk")
    -- Using the priority order for Streaming Services, get the direct link to the program ( Hoopla > Netflix, therefore, use Hoopla Stream Link)
    -- IF it is a manual program, just create the .strmlnk

  5. Monitor
    -- IF the Program becomes available on a higher priority Streaming Service, replace the contents of the .strmlnk file with that Stream Link.
    -- IF the Program changes to not available on any selected Streaming Services, delete the .strmlnk file and run the Channels Prune operation (user option whether to allow this last step to happen automatically or not).
    -- IF the Program is currently not available on any selected Streaming Services, but then becomes available, run the Stream Link Generator process from above on it.
    -- IF the Program is deleted in Channels, remove the Tick for the Program in the interface so that the .strmlnk file is not automatically created.

4 Likes

No, that would break functionality. There are many use cases where either the Streaming Service does not have all episodes or the user only wants some because they've seen the others. Or maybe they only want new episodes when they become available. Interactivity and user selection is a requirement. The method for interactivity is still in the design phase. See the post above this one for more details.

Thanks, but what I've put out so far is about 2% of what the real Stream Link Manager is intended to do. I'm sure it will make a fine OliveTin deployment in the future, but for now, I'm just taking an open agile development approach with proof of concept components. For the foreseeable future, it would not be worth your time to try to take anything I've done or am doing and migrate it over, even for proof of concept. Too much is in flux and not anywhere near a final product.

2 Likes

ProtoType_Stream_Link_Maker.py

import os
import re
import urllib.parse
import datetime

def current_time():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + ": "

# Install simple-justwatch-python-api (https://github.com/Electronic-Mango/simple-justwatch-python-api) if not already installed
try:
    from simplejustwatchapi.justwatch import search, details
except ImportError:
    if os.name == "posix":  # Unix-like systems (including macOS)
        os.system("pip3 install simple-justwatch-python-api")
    else:   
        os.system("pip install simple-justwatch-python-api")

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

# 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.")

    print("")
    print(f"{current_time()} Chosen Directory: {directory_path}")
    print("")

    return directory_path

# Get user input from a popup box or command line.
def get_user_input(prompt):
    while True:
        if os.name == "posix":  # Unix-like systems (including macOS)
            user_input = input(prompt)
        else:
            try:
                import tkinter as tk
                from tkinter import simpledialog, messagebox
                root = tk.Tk()
                root.withdraw()  # Hide the main window
                user_input = simpledialog.askstring("Input", prompt)
                root.destroy()  # Close the pop-up window
            except ImportError:
                user_input = input(prompt)

        # Validate if a value was entered
        if user_input is not None and user_input.strip():
            return user_input.strip()  # Remove leading/trailing spaces
        else:
            print(f"{current_time()} Error: Please provide a valid input.")

# Get user selection from buttons or command line list.
def get_button_selection(prompt, options):
    while True:
        if os.name == "posix":  # Unix-like systems (including macOS)
            print(prompt)
            for i, option in enumerate(options, start=1):
                print(f"{i}. {option}")
            try:
                choice = int(input("Enter the number corresponding to your choice: "))
                if 1 <= choice <= len(options):
                    return options[choice - 1]
                else:
                    print("Invalid choice. Please try again.")
            except ValueError:
                print("Invalid input. Please enter a valid number.")
        else:
            try:
                import tkinter as tk
                root = tk.Tk()
                root.withdraw()  # Hide the main window

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

                # Calculate the height of the dialog based on the number of options
                dialog_height = 150 + len(options) * 30
                dialog.geometry(f"300x{dialog_height}")

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

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

                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
                if selected_option:
                    return selected_option
                else:
                    print(f"{current_time()} Error: Please select an option.")
            except ImportError:
                return input(prompt)

# Remove invalid characters (e.g., colons, slashes, etc.)
def sanitize_name(name):
    sanitized = re.sub(r'[\\/:*?"<>|]', '', name)
    return sanitized

# Create a directory if it doesn't exist.
def create_directory(directory_path):
    if not os.path.exists(directory_path):
        os.makedirs(directory_path)

# Create season folders with proper naming.
def create_season_folders(base_directory, starting_season, ending_season):
    for season_number in range(starting_season, ending_season + 1):
        season_folder_name = f"Season {season_number:02}"
        season_folder_path = os.path.join(base_directory, season_folder_name)
        create_directory(season_folder_path)

# Create episode files within a season folder.
def create_episode_files(season_folder, episode_count, prefix, episode_url):
    for episode_number in range(1, episode_count + 1):
        episode_name = f"S{season_folder[-2:]}E{episode_number:02}"
        if prefix != "None":
            episode_name = f"{prefix} {episode_name}"
        episode_name += ".strmlnk"
        episode_file_path = os.path.join(season_folder, episode_name)
        print(f"{current_time()} Creating: {episode_file_path}")
        if not os.path.exists(episode_file_path):
            with open(episode_file_path, 'w') as file:
                file.write(episode_url)

# Create movie files
def create_movie_file(movie_directory, movie_name_base, movie_url):
    movie_name = f"{movie_name_base}"
    movie_name += ".strmlnk"
    movie_file_path = os.path.join(movie_directory, movie_name)
    print(f"{current_time()} Creating: {movie_file_path}")
    if not os.path.exists(movie_file_path):
        with open(movie_file_path, 'w') as file:
            file.write(movie_url)

# Find all the Streaming Services for the selected Program
def extract_offer_info(offers):
    result = []
    for offer in offers:
        name = offer.package.name
        price_string = offer.price_string if offer.price_string is not None else "$0.00"
        url = urllib.parse.unquote(offer.url)  # Decode the URL
        result.append({"name": name, "price_string": price_string, "url": url})
    return result

# Input the country code to search
def get_country_code():
    while True:
        country_code_input = input("Enter a 2-letter country code (or press Enter for default 'US'): ")
        if not country_code_input:
            return "US"  # Default value
        elif len(country_code_input) == 2 and country_code_input.isalpha():
            return country_code_input.upper()
        else:
            print("")
            print("Invalid input. Please enter a valid 2-letter country code.")
            print("")

# Input the language code to search
def get_language_code():
    while True:
        language_code_input = input("Enter a 2-letter language code (or press Enter for default 'en'): ")
        if not language_code_input:
            return "en"  # Default value
        elif len(language_code_input) == 2 and language_code_input.isalpha():
            return language_code_input.lower()
        else:
            print("")
            print("Invalid input. Please enter a valid 2-letter language code.")
            print("")

# Input the number of search results to return
def get_num_results():
    while True:
        try:
            num_results_input = input("Enter the number of search results (or press Enter for default '9'): ")
            if not num_results_input:
                return 9  # Default value
            num_results = int(num_results_input)
            if num_results > 0:
                return num_results
            else:
                print("")
                print("Please enter a positive integer.")
                print("")
        except ValueError:
            print("")
            print("Invalid input. Please enter a valid positive integer.")
            print("")

# Main Function
def main():
    print("")

    # Input the country code
    country_code = get_country_code()
    print("")
    print(f"{current_time()} Selected country code: {country_code}")
    print("")

    # Input the language code
    language_code = get_language_code()
    print("")
    print(f"{current_time()} Selected language code: {language_code}")
    print("")

    # Input the number of search results
    num_results = get_num_results()
    print("")
    print(f"{current_time()} Selected number of search results: {num_results}")
    print("")

    # Loop to run again
    while True:

        # Search for a program
        while True:
            print("")
            program_search = input("Enter the program to search for: ")
            program_search_base = search(program_search, country_code, language_code, num_results, True)

            # Extract the titles, release years, and object types from the response
            program_search_results = [(entry.title, entry.release_year, entry.object_type, entry.entry_id) for entry in program_search_base]

            # Display a list of options to the user
            print("")
            print("Select an option (or enter 0 to cancel and retry):")
            print("")
            for i, (title, release_year, object_type, entry_id) in enumerate(program_search_results, start=1):
                print(f"{i}. {title} ({release_year}) | {object_type}")

            while True:
                # Get user input for the selected option
                print("")
                program_search_selected = input("Enter the number of the option you want to choose: ")
                print("")

                # Validate user input
                try:
                    program_search_index = int(program_search_selected) - 1
                    if program_search_index == -1:
                        print("")
                        print(f"{current_time()} Selection canceled. Returning to the top.")
                        print("")
                        retry_step = 0
                        break
                    elif 0 <= program_search_index < len(program_search_base):
                        program_search_selected_entry_id = program_search_base[program_search_index].entry_id
                        program_search_selected_object_type = program_search_base[program_search_index].object_type
                        program_search_selected_program = f"{program_search_base[program_search_index].title} ({program_search_base[program_search_index].release_year})"
                        print("")
                        print(f"{current_time()} You selected: {program_search_selected_program}")
                        print(f"{current_time()} The selected program is a: {program_search_selected_object_type}")
                        print(f"{current_time()} The corresponding entry ID is: {program_search_selected_entry_id}")
                        print("")
                        retry_step = 1
                        break
                    else:
                        print("")
                        print(f"{current_time()} Invalid option. Please choose a valid number.")
                        print("")
                        retry_step = 3
                        continue
                except ValueError:
                    print("")
                    print(f"{current_time()} Invalid input. Please enter a valid number.")
                    print("")
                    retry_step = 3
                    continue

            if retry_step == 0:
                continue
            elif retry_step == 1:
                break

        # Select a Streaming Services Option
        while True:
            # Get the Streaming Services Offer based upon the selected Program
            services_search_base = details(program_search_selected_entry_id, country_code, language_code, True)
            services_search_results = extract_offer_info(services_search_base.offers)

            if not services_search_results:
                print(f"{current_time()} {program_search_base[program_search_index].title} is not currently available on any Straming Service.")
                print("")
                run_create = False
                break
            else:
                run_create = True

            # Display a list of Streaming Services Options to the user
            print("")
            print("Select a Streaming Service (or enter 0 to cancel and exit):")
            print("")
            for i, info in enumerate(services_search_results, start=1):
                print(f"{i}. {info['name']} ({info['price_string']})")

            while True:
                # Get user input for the selected Streaming Services Option
                print("")
                services_search_selected = input("Enter the number of the Streaming Service Option you want to choose: ")
                print("")

                # Validate user input
                try:
                    services_search_index = int(services_search_selected) - 1
                    if services_search_index == -1:
                        print("")
                        print(f"{current_time()} Cancelling...")
                        print("")
                        run_create = False
                        retry_step = 1
                        break
                    elif 0 <= services_search_index < len(services_search_results):
                        services_search_selected_service = services_search_results[services_search_index]
                        services_search_selected_url = services_search_selected_service['url']  # Save the URL
                        print("")
                        print(f"{current_time()} You selected: {services_search_selected_service['name']} ({services_search_selected_service['price_string']})")
                        print(f"{current_time()} The corresponding URL is: {services_search_selected_url}")
                        print("")
                        retry_step = 1
                        break
                    else:
                        print("")
                        print(f"{current_time()} Invalid option. Please choose a valid number.")
                        print("")
                        retry_step = 3
                        continue
                except ValueError:
                    print("")
                    print(f"{current_time()} Invalid input. Please enter a valid number.")
                    print("")
                    retry_step = 3
                    continue

            if retry_step == 0:
                continue
            elif retry_step == 1:
                break

        # Create directories (if ncessary) and file(s)
        if run_create:
            program_name = sanitize_name(program_search_selected_program)

            print("")
            print("Select a top level directory for the Stream Link(s):")
            print("")
            base_directory = get_directory()

            if program_search_selected_object_type == "SHOW":
                prefix_options = ["None", "(DUB)", "(SUB)"]
                selected_prefix = get_button_selection("Select a Prefix option for the episodes:", prefix_options)
                starting_season = int(get_user_input("Enter the Starting Season Number: "))
                ending_season = int(get_user_input("Enter the Ending Season Number: "))

                create_directory(os.path.join(base_directory, program_name))

                create_season_folders(os.path.join(base_directory, program_name), starting_season, ending_season)

                for season_number in range(starting_season, ending_season + 1):
                    season_folder_name = f"Season {season_number:02}"
                    season_folder_path = os.path.join(base_directory, program_name, season_folder_name)
                    episode_count = int(get_user_input(f"Number of Episodes for {season_folder_name}: "))
                    create_episode_files(season_folder_path, episode_count, selected_prefix, services_search_selected_url)

                print(f"{current_time()} Directory structure and files created successfully.")

            elif program_search_selected_object_type == "MOVIE":
                
                create_movie_file(base_directory, program_name, services_search_selected_url)

                print(f"{current_time()} File created successfully")

            else:
                print("")
                print(f"{current_time()} ERROR: Unknown Program Type")
                print("")

        # Check to re-run
        rerun_options = ["Yes", "No"]
        rerun_select = get_button_selection("Do you wish to run again? (Yes/No):", rerun_options)

        if rerun_select == "Yes":
            continue
        elif rerun_select == "No":
            break
        else:
            print("")
            print(f"{current_time()} Error encountered, exiting...")
            print("")
            break

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

if __name__ == "__main__":
    main()

The above script is a prototype proof-of-concept for the program search, selection of a program, and creation of expected Stream Link files. This is a fully functioning application with the following caveats:

  1. TV Shows are not getting individual episode URLs (yet). The API that is used to access the base data needs an update before that is possible. Also, given this current situation, I have opted not to get more granular with episode selection at this time.

  2. The Stream Links that are created might not be valid. I have especially noticed an issue with Disney+ that I'm working on a higher level approach to address. Although I know how to solve the issue for Disney+, I do not want to create fixes for individual services as it will be way too much to manage.

  3. In a similar vein, although almost all Hulu programs are available on Disney+, the underlying data provider is not up to date on this fact and will usually only offer Hulu instead of Disney+ and Hulu. Again, I already have a resolution for this, but would rather avoid dealing with a specific service this way.


That all said, here's how it looks:

At the beginning, you are prompted for certain conditions for searching. If you want the default options (United States, English, 9 Results), then you can just hit enter and parse past them.

image

At this point you can search for whatever you are looking for, not case sensitive. If you get enter-happy or forget to put anything, it will just return the most popular programs right now. Otherwise, you will see this:

image

From here, you can select 0 to search again, or select one of the numbers. If you mistype, it will warn you and let you select again until you make a valid choice. In this case, I'm going to select the first option:

image

The application confirms your choice and then offers you everywhere it is available and at what price (assuming you have a subscription for the $0.00 options). If it is not available anywhere, you will be told so and forced to the end of the routine (more on this later). Otherwise, similar to the prior step, you must select a valid option to move on, or 0 to cancel and maybe try again. I'm going to choose 9 for YouTube:

A confirmation appears for the selection and the URL. At this point, you would select a the directory where you want the file to be created (or, if a show, the parent directory). Note that because I am using Windows I am getting a pop-up selection box, but command line only systems would have to type in a directory path. This is the case for all popups below. Later, these will not exist and instead will default into their own directory in the Imports directory under Channels.

After selecting the folder, everything progresses as normal:

image

image

image

As can be seen above, a file is created automatically with the correct name and Stream Link inside!

Afterwards, you'll be asked if you want to run again and do another search.

JP_ScreenShot_2024_04_30_17_31_31

This will bring you back to the search menu. If you do any of the earlier opportunities to cancel, you will also end up here. Now that we are here, let's go through a TV Show. Back to the prior test, I'm going to select 6.

Now I get prompts for prefixes, number of seasons, and number of episodes per season. In the future, this will not be a selection at this point but part of the management system:

image

image

image

image

image

This all results in individual files being created:

Take note that the program had an invalid character (a colon :) that was removed. This would happen with a movie, as well.

And that's it! With this, we have proven that we can search a 3rd party source and create Stream Links based upon it. I'll also be looking into the reverse of this with selecting a Streaming Service and being presented with the newest additions to see if you want to create the Stream Links.

Enjoy!

2 Likes

This is looking good but is there a way to prevent the script from exiting after selection of one show/movie? I got multiple shows/movies that I want to add so going back and opening up the script all the time could be tedious

Done and done!

JP_ScreenShot_2024_04_30_17_31_31

I've updated the script code and the example (a bit) in the post above.

1 Like

A quick status update...

image

Stream Link Manager for Channels is 90% ready for an alpha release. Here's where everything stands:

image

  • Settings
    -- Set Channels URL
    ---- 100% Complete
    ---- Defaults to current server
    ---- Can be set to any URL/Port
    -- Set Channels Folder
    ---- 100% Complete
    ---- Defaults to current folder
    ---- Can navigate and select through the text menu to any folder
    -- Select Streaming Services
    ---- 100% Complete
    ---- Automatically updates list of available streaming services (adds and removes)
    ---- Limited to streaming services available in set country
    -- Prioritize Streaming Services
    ---- 100% Complete
    ---- Newly selected streaming service ends up lowest priority
    ---- Can move streaming services up and down in prioritization
    -- Set Search Defaults
    ---- 100% complete
    ---- Defaults to Location: United States, Language: English, Number of Results: 9
    ---- Can override during search
    ---- Automatic set of location during first launch to get appropriate Streaming Services
    -- Advanced / Experimental Settings
    ---- Currently only have converting Hulu stream links to Disney+
    ---- Option exists, but have not implemented (work in progress)
    ---- OPEN QUESTION: Are there more to consider?

  • Search and Bookmark Programs
    -- 100% Complete
    -- Can search for any movie/show (or get most popular), get descriptions
    -- image
    -- Can select any movie/show
    -- Movies/Shows always remain bookmarked (warns if already bookmarked)
    -- When selecting a show, pick which episodes are unwatched
    -- image
    -- OPEN QUESTION: Should functionality for episode prefixes [i.e., "(SUB) ", "(DUB) "] be added?

  • Add Manual Programs
    -- 100% Complete
    -- Has all the same functionality of search, just have to enter program name and release year
    -- Manually enter in Stream Link URLs (never removed)
    -- Keeps track of manual programs versus search programs

  • Check for New Episodes
    -- 100% Complete
    -- Detects any added episode for search bookmarks and adds to bookmarks
    -- New episodes are always "unwatched"

  • Import Program Updates from Channels
    -- 100% Complete
    -- Detects deleted Stream Link files
    -- Marks movie/episode as watched, thus eliminating Stream Link creation

  • Manually Modify Programs and Episodes
    -- 100% Complete
    -- Can change name, release year, watched status, and stream link URL (this is overwritten for programs added using the search method)
    -- Can add new episodes
    -- No impact on Stream Link generation

  • Generate Stream Links
    -- 90% Complete
    -- Watched/Unwatched status and availability on selected streaming services determines if a Stream Link is created
    -- Gets stream link URLs
    ---- 100% working for movies (search and manual) and manual shows
    ---- Open issue getting stream link URLs for search shows (work in progress)
    ---- Open issue cleaning up all stream link URLs to get rid of tracking and have an appropriate format for deep linking (work in progress)
    ------ Also need to add Hulu to Disney+ functionality
    -- 100% creates all necessary directories and files in the expected location and populates with stream link URLs
    -- 100% removes watched/unavailable Stream Links and deletes empty directories.

  • Prune/Scan in Channels
    -- 100% Complete
    -- Runs a "Prune Personal Media" in Channels
    -- Runs a "Scan Personal Media" in Channels

  • Future Functionality (not for this build)
    -- Scheduling system to run Check for New Episodes, Import Program Updates from Channels, Generate Stream Links, and Prune/Scan in Channels in succession
    -- Web page GUI
    -- Docker container and/or local executable

6 Likes

Seeking Alpha Testers

Stream Link Manager for Channels Alpha is now complete and in closed unit testing. At the moment, that just includes me, but I'll soon be interested in expanding to a wider pool of people with different setups to fully kick the tires. If you're interested, please PM me with the following information:

  1. Operating System (i.e., Windows, MacOS, Linux)
  2. Streaming Services you use (i.e., Netflix, Disney+, Max, etc...)
  3. Familiarity/Comfort level with Python and Batch/Bash commands (not required to have; possessing little to no knowledge is useful, too)

So for me, this is:

  1. Windows
  2. Disney+, Hulu, Kanopy, Hoopla
  3. Moderate-Strong understanding of Python, High level for Batch, Low-Moderate for Bash

image


Status Updates

  • Startup
    -- image
    -- There is now a batch/bash script available for running
    -- Users no longer need to know anything about Python
    -- Batch/Bash script automatically does the installation, updates the code, and launches the program

  • Scheduling
    -- image
    -- Can now set a time for a process to run daily that:
    ---- Checks for New Episodes
    ---- Imports Program Updates from Channels
    ---- Generates Stream Links
    ---- Runs a Prune/Scan in Channels
    -- Default time is the first time you run the program
    -- Can always create/update and remove the scheduled task

  • Stream Link URLs
    --


    -- Can now get Stream Link URLs for individual episodes 100% (Level Up! New skill acquired... GraphQL)
    ---- Episode data is currently not as good as hoped from source. This is a function of the provider and there is nothing I can do about it save them updating their own data or me building my own database.
    ---- Some sources like Netflix and Disney+ will only bring you to the series landing page
    ---- Other sources like Paramount+ and Hulu have very good episode data
    ---- However, with something like Hulu, if there is a program available both in SUB and DUB, they are separate listings. The Stream Link URLs tend to be the SUB versions.
    ---- Workaround detailed below
    -- Hulu to Disney+ Conversion working 100% (with same warning from above)
    -- URL Clean-up does many different levels
    ---- Might be too aggressive for some services
    ---- Might be other fixes that can be done for individual services that I have not checked
    ---- This will be a core Alpha Tester activity

  • Manual/Override Controls
    --


    -- As a workaround to the episode issues noted above, any program/episode can have its URL be overwritten with a manual entry.
    -- Can now add any Prefix you want to episodes [i.e., (SUB), (DUB)]

  • General Quality Improvements
    -- Further simplification, removing repeating elements, lower resource utilization, bug fixes
    -- Program checks for and removes rogue files from folders. For instance, if you added a prefix after an episode file was originally created, it will create the prefix file and get rid of the non-prefix one.
    -- Notification when an override Stream Link is used instead of searching for one


Coming Soon

  • Video Demonstration
  • Closed Alpha Testing
  • Migration to Github
  • Alpha Open Release
4 Likes

:heart: I pinned the topic globally to get some visibility to the alpha.

3 Likes