[RELEASE] Stream Link Manager for Channels

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

5 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

Just noticed a typo:

image

:wink:

That looks great. I will PM you my info.

1 Like

Hehehe, thanks for proving why I need testers and other sets of eyes!

FYI, now fixed!

Not a problem, it happens to the best of us. :wink:

A big part of my job during the week is to do code and document reviews. When I really focus on a review, I can find small details. Not to say I notice everything but I'm trained to look for these kind of things. It's second nature to me now. :grin:

Anyway, this project looks great and I am looking forward to helping with testing it. :smiley:

1 Like

I am very interested in this. However, I am in no way a Dev or very knowledgeable with Python/Bash stuff, etc. I can pretty easily follows directions on what to do to set something like this up, but not sure how difficult that would be in my case.

Is it pretty easy to do with some directions? Just don't want to waste your time if I should just wait but interested in this becoming a thing.

In no way would a tester like you be a waste of time! My goal is that you shouldn't need to know anything about coding, just how to double click a file. Please PM me with the answers to the above so I can make sure I have your info when I'm ready to release to the group. Before then, I'll be posting a demo video, too. I definitely need non-techie eyes to make sure the interface and navigation makes sense.

2 Likes

:+1:t3: Thanks to everyone who has volunteered so far for alpha testing! We have a pretty decent mix of systems, services, and skills. Since I'll want to keep this test small and varied, I'm going to be a bit more discerning about adding others if you happen to be coming across this later.

I've been doing some pretty tough tests myself, including converting all my movies and some TV shows. The program is working really well, so I've also been focused on more efficiencies and ease of use. Last time I mentioned batch/bash files, but have now moved to stand-alone executables (which caused so many issues to fix :dizzy_face:). As such, you shouldn't even need to install Python anymore!

image
Yes, there will be MacOS and Linux versions, as much of a pain as they are going to be for me create. :upside_down_face:

Now that I've done this, I'm still considering how to deal with updating. This might mean the return of the batch/bash scripts, or leaving it to users to manually download an update.

2 Likes

Watch the demo!

Alpha testing is underway, so the next update will be the Beta release.

4 Likes

Watching the video right now and excited to test on Mac when it’s ready! In the meantime I caught another small typo on the first screen when asking for country, it says “steaming service” instead of “streaming” :slight_smile:

just watched the video and looks great so far.
Looking forward to the graphical interface eventually but this is a nice start.