diff --git a/.github/workflows/functionality_tests.yml b/.github/workflows/functionality_tests.yml index b54c7bc..2761aeb 100644 --- a/.github/workflows/functionality_tests.yml +++ b/.github/workflows/functionality_tests.yml @@ -63,7 +63,7 @@ jobs: shell: bash - name: Import subscription run: | - python3 ./yt_manager.py import-subscriptions ./ytdownloader/subscriptions_export.json True + python3 ./yt_manager.py import-subscriptions ./ytdownloader/subscriptions_export.json --overwrite True EXIT_CODE=$? if [ $EXIT_CODE -eq 0 ]; then echo "Command succeeded with exit code 0" diff --git a/README.md b/README.md index a1a16a9..5aabb35 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ - [![GPLv3 License](https://img.shields.io/badge/License-GPL%20v2-green.svg)](https://opensource.org/licenses/) [![CodeFactor](https://www.codefactor.io/repository/github/j54j6/youtubedl-downloader/badge)](https://www.codefactor.io/repository/github/j54j6/youtubedl-dowloader) @@ -33,6 +32,7 @@ For Reference please look in the project.json section - Create and import Backups - Validate your FS - Show duplicates +- Format profiles (globally and per custom download / subscription) # Currently supported Sites - Pinterest @@ -68,22 +68,22 @@ Options: ``` With the provided command only subscriptions created with the youtube or reddit scheme are shown. If no filter is passed all subscriptions will be shown like below. If the scheme changes a divider is inserted ``` -+----+----------------+---------+---------------+-------------------+---------------------+------------------------------------------------------+ -| ID | Name | Scheme | Avail. Videos | Downloaded Videos | Last checked | url | -+----+----------------+---------+---------------+-------------------+---------------------+------------------------------------------------------+ -| 1 | test | reddit | 29 | 0 | 2024-05-19 11:59:55 | https://reddit.com/ | -+----+----------------+---------+---------------+-------------------+---------------------+------------------------------------------------------+ -| 2 | @AlexiBexi | youtube | 828 | 0 | 2024-05-19 12:07:04 | https://www.youtube.com/@AlexiBexi/videos | -| 3 | @Lohntsichdas | youtube | 181 | 0 | 2024-05-19 14:28:50 | https://www.youtube.com/@Lohntsichdas/videos | -| 4 | @Finanzfluss | youtube | 635 | 0 | 2024-05-19 14:29:05 | https://www.youtube.com/@Finanzfluss/videos | -| 5 | @DoktorWhatson | youtube | 288 | 0 | 2024-05-19 14:29:25 | https://www.youtube.com/@DoktorWhatson/videos | -+----+----------------+---------+---------------+-------------------+---------------------+------------------------------------------------------+ ++----+----------------+---------+---------------+-------------------+---------------------+------------------------------------------------------+--------------- +| ID | Name | Scheme | Avail. Videos | Downloaded Videos | Last checked | url |output-format | ++----+----------------+---------+---------------+-------------------+---------------------+------------------------------------------------------+--------------+ +| 1 | test | reddit | 29 | 0 | 2024-05-19 11:59:55 | https://reddit.com/ |m4a | ++----+----------------+---------+---------------+-------------------+---------------------+------------------------------------------------------+--------------+ +| 2 | @AlexiBexi | youtube | 828 | 0 | 2024-05-19 12:07:04 | https://www.youtube.com/@AlexiBexi/videos | | +| 3 | @Lohntsichdas | youtube | 181 | 0 | 2024-05-19 14:28:50 | https://www.youtube.com/@Lohntsichdas/videos | | +| 4 | @Finanzfluss | youtube | 635 | 0 | 2024-05-19 14:29:05 | https://www.youtube.com/@Finanzfluss/videos | | +| 5 | @DoktorWhatson | youtube | 288 | 0 | 2024-05-19 14:29:25 | https://www.youtube.com/@DoktorWhatson/videos | | ++----+----------------+---------+---------------+-------------------+---------------------+------------------------------------------------------+--------------+ ``` ### Add Subscriptions To add a subscription the overview url of the channel/playlist is needed. For example: ``` - yt_manager.py add-subscription https://www.youtube.com/@AlexiBexi/ + yt_manager.py add-subscription https://www.youtube.com/@AlexiBexi/ --output-format <> ``` or if you want to add multiple ``` @@ -112,6 +112,46 @@ or yt_manager.py del-subscription https://www.youtube.com/@AlexiBexi ``` +# Format handling +If you want to change the output format you can create profiles (or use the pre defined ones)... +Currently you can only edit / add profiles in the db or add them manually inside the "formats.json" file. TZhe program will import the data automatically + +You can define the output format in different ways: + ## General Info + In any case: If you define an output profile (for custom downlaods or subscriptions) they overrule all other settings! + ### Subscriptions: + If you create a new subscription you can pass the "--output-format" flag and pass a profile in which all data will be downloaded (list is possible) + ### Custom downlaods + For custom videos you can also pass the "--output-format" flag to define the output format + + ### Nothing passed (Global) + If nothing is passed you need to use the global settings. Profiles can be enabled or disabled. If enabled + all filedownloads without explicit output format passed will be downloaded using ALL enabled profiles. + You can have multiple profiles enabled at the same time! + ### Fallback + The fallback if something breaks is "best"... You can change it in the config.ini file! + + ## Commands + ### Show defined profiles and states + + This command allows you to list all available profiles and see if profiles are enabled or disabled + ``` + yt_manager.py show-format-profiles + ``` + + ### Disable a profile (globally) + You can disable profiles for global use (not counted for subscriptions!) + ``` + yt_manager.py disable-format-profile <> + ``` + + ### Enable a profile (globally) + You can enable profiles by using this command. Important: Multiple profiles can be active. You can use the + optional flag "only_active" to disable all other enabled profiles! + ``` + yt_manager.py enable-format-profile <> --only_active + ``` + ## Backup functionalities ### Export Subscriptions You can create a backup file of your subscriptions. The file will be saved in your base dir (defined in config scheme/db) @@ -166,7 +206,7 @@ A batch mode is planned but not implemented yet! Example use: ``` - yt_manager.py custom https://www.youtube.com/watch?v=gE_FuQuaKc0 + yt_manager.py custom https://www.youtube.com/watch?v=gE_FuQuaKc0 --output-format <> ``` or if you want to download multiple links ``` @@ -363,6 +403,4 @@ subscription_data => metadata of the subscription. This field can be very very l - [PrettyTables](https://github.com/jazzband/prettytable) - [TLDExtract](https://github.com/john-kurkowski/tldextract) - [Validators](https://github.com/python-validators) - - [Awesome Readme Templates](https://awesomeopensource.com/project/elangosundar/awesome-README-templates) - - + - [Awesome Readme Templates](https://awesomeopensource.com/project/elangosundar/awesome-README-templates) \ No newline at end of file diff --git a/config.ini b/config.ini index e46a6ad..4883788 100644 --- a/config.ini +++ b/config.ini @@ -8,3 +8,4 @@ db_pass = password [other] timezone=Europe/Berlin +fallback_format=best \ No newline at end of file diff --git a/database_manager.py b/database_manager.py index cc48e6c..0b9978a 100644 --- a/database_manager.py +++ b/database_manager.py @@ -216,6 +216,41 @@ def prepare_sql_create_statement(name, scheme): query +=");" return query +def prepare_sql_add_column_statement(table_name, column_name, options): + """ This function is used to add columns to an existing table based on a defined json scheme. + Check documentation for help + + Return Values: + - None -> Error + - SQL Statement + """ + query:str = f"ALTER TABLE {table_name} ADD COLUMN {column_name}" + + #Iterate over all defined columns. Check for different optionas and add them to the query. + + #For each column create a cache query based on SQL -> <> <> <> + if not "type" in options: + #PyLint C0301 + logger.error("""Error while creating table! - + Column %s does not include a valid \"type\" field!""", column_name) + return None + c_query:str = "" + c_query += " " + options["type"] + if "not_null" in options and options["not_null"] is True and "default" in options and options["default"] != "": + c_query += " NOT NULL" + if "primary_key" in options and options["primary_key"] is True: + logger.warning("Altering a table and changing the primary key is NOT supported!") + if "auto_increment" in options and options["auto_increment"] is True: + logger.warning("Altering a table and changing the primary key is NOT supported! -> Auto increment is also disabled...") + if "unique" in options and options["unique"] is True: + c_query += " UNIQUE" + if "default" in options: + c_query += " DEFAULT " + options["default"] + query += c_query + ", " + query = query[:-2] + query +=";" + return query + def create_table(name:str, scheme:json): """This function can create a table bases on a defined JSON scheme @@ -274,6 +309,52 @@ def create_table(name:str, scheme:json): logger.error("Error while creating table %s Error: %s", name, e) return False +def check_scheme_match(table_name: str, scheme:json): + """ + This function is used to check if a given (existing) table is matching a given scheme (it checks if all columns of the scheme actually existing inside the db table) + If there are missing columns they will be added (but nothing removed!) + """ + if not db_init: + init = check_db() + if not init: + logger.error("Error while initializing DB") + return False + #Check if the table already exist. If so - SKIP + if not check_table_exist(table_name): + logger.warning("Table %s does not exist! - Can't check if the table matches a scheme...", table_name) + return False + + #Fetch all rows of the table + cursor = cursor = ENGINE.cursor() + cursor.execute("SELECT * from " + table_name) + ENGINE.commit() + + names = list(map(lambda x: x[0], cursor.description)) + + missing_columns:list = [] + + for needed_column in scheme: + if not needed_column in names: + print(needed_column) + print("Is missing") + missing_columns.append(needed_column) + + if len(missing_columns) > 0: + logger.info("Table %s misses %i columns. Add missing columns...", table_name, len(missing_columns)) + for missing_column in missing_columns: + try: + sql_statement = prepare_sql_add_column_statement(table_name, missing_column, scheme[missing_column]) + cursor = cursor = ENGINE.cursor() + cursor.execute(sql_statement) + ENGINE.commit() + return True + except sqlite3.Error as e: + logger.error("Error while adding column %s to table %s Error: %s", missing_column, table_name, e) + return False + else: + logger.info("Table %s is up to date...", table_name) + return True + def fetch_value(table:str, conditions:dict|list=None, data_filter:dict|list = None, is_unique=False, extra_sql=None): """ Fetch a value from a database based on a json filter {""} """ diff --git a/project_functions.py b/project_functions.py index 136fb66..4f2c10f 100644 --- a/project_functions.py +++ b/project_functions.py @@ -37,7 +37,7 @@ #own modules from database_manager import (check_table_exist, create_table, update_value, - insert_value, fetch_value, fetch_value_as_bool, delete_value) + insert_value, fetch_value, fetch_value_as_bool, delete_value, check_scheme_match) from config_handler import config # init logger @@ -69,7 +69,7 @@ def start(): ################# Subscription related -def add_subscription(url:str, downloaded:int = None, last_checked = None, meta_data = None): +def add_subscription(url:str, downloaded:int = None, last_checked = None, meta_data = None, output_format:list[str] = None): """ Add a subscription to the database Return Values: @@ -89,12 +89,19 @@ def add_subscription(url:str, downloaded:int = None, last_checked = None, meta_d subscription_exist[1], subscription_exist[2]) return True - subscription_obj = get_subscription_data_obj(url, downloaded, last_checked, meta_data) - + #Check if the defined outputformat exists (if it is not none) + if output_format is not None: + for desired_format in output_format: + if not check_format_profile_exist(desired_format): + logger.error("The specified format %s is not a valid format profile!", desired_format) + return False + + subscription_obj = get_subscription_data_obj(url, downloaded, last_checked, meta_data, output_format) + if not subscription_obj["status"]: logger.error("Error while creating subscription obj!") return False - + #Check if the formatted link is already in db - This is url should every time the same if subscription_obj["exist_in_db"]: logger.info("%s subscription for %s already exists!", subscription_exist[1], @@ -111,7 +118,7 @@ def add_subscription(url:str, downloaded:int = None, last_checked = None, meta_d subscription_obj["obj"]["subscription_name"]) return True -def add_subscription_batch(file:str): +def add_subscription_batch(file:str, output_format:list[str] = None): """ Add a subscription to the database using a file Return Values: @@ -127,7 +134,7 @@ def add_subscription_batch(file:str): with open(file, 'r', encoding="UTF-8") as input_file: for line in input_file: line = line.strip() - if not add_subscription(line): + if not add_subscription(line, output_format): failed = True if not failed: @@ -196,7 +203,8 @@ def list_subscriptions(scheme_filter:list=None): "subscription_content_count", "downloaded_content_count", "subscription_last_checked", - "subscription_path" + "subscription_path", + "output_format" ], extra_sql="ORDER BY scheme") else: @@ -214,7 +222,8 @@ def list_subscriptions(scheme_filter:list=None): "subscription_content_count", "downloaded_content_count", "subscription_last_checked", - "subscription_path" + "subscription_path", + "output_format" ], extra_sql="ORDER BY scheme") if subscriptions is None: @@ -222,7 +231,7 @@ def list_subscriptions(scheme_filter:list=None): return False subscriptions_table = PrettyTable( - ['ID', 'Name', 'Scheme', 'Avail. Videos', 'Downloaded Videos', 'Last checked', 'url']) + ['ID', 'Name', 'Scheme', 'Avail. Videos', 'Downloaded Videos', 'Last checked', 'url', 'format']) subscriptions_table.align['ID'] = "c" subscriptions_table.align['Name'] = "l" subscriptions_table.align['Scheme'] = "l" @@ -230,6 +239,7 @@ def list_subscriptions(scheme_filter:list=None): subscriptions_table.align['Downloaded Videos'] = "c" subscriptions_table.align['Last checked'] = "c" subscriptions_table.align['url'] = "l" + subscriptions_table.align['format'] = "c" video_is = 0 video_should = 0 for index, subscription in enumerate(subscriptions): @@ -247,6 +257,10 @@ def list_subscriptions(scheme_filter:list=None): if enable_divider: logger.debug("For ID %s no divider needed!", subscription[0]) + if subscription[7] is None: + output_format = "global" + else: + output_format = subscription[7] subscriptions_table.add_row([ subscription[0], subscription[1], @@ -254,10 +268,15 @@ def list_subscriptions(scheme_filter:list=None): subscription[3], subscription[4], subscription[5], - subscription[6]], + subscription[6], + output_format], divider=True) else: logger.debug("For ID %s no divider needed!", subscription[0]) + if subscription[7] is None: + output_format = "global" + else: + output_format = subscription[7] subscriptions_table.add_row([ subscription[0], subscription[1], @@ -265,11 +284,12 @@ def list_subscriptions(scheme_filter:list=None): subscription[3], subscription[4], subscription[5], - subscription[6]], + subscription[6], + output_format], divider=False) enable_divider = False - subscriptions_table.add_row(["Total: ",len(subscriptions),'',video_should,video_is,'','']) + subscriptions_table.add_row(["Total: ",len(subscriptions),'',video_should,video_is,'','', '']) print(subscriptions_table) return True @@ -416,7 +436,8 @@ def export_subscriptions(): "subscription_last_checked", "downloaded_content_count", "last_subscription_data", - "subscription_name"]) + "subscription_name", + "output_format"]) if subscriptions is None: logging.error("Error while fetching subscriptions") @@ -428,7 +449,8 @@ def export_subscriptions(): "subscription_last_checked": subscription[1], "downloaded_content_count": subscription[2], "last_subscription_data": subscription[3], - "subscription_name": subscription[4] + "subscription_name": subscription[4], + "output_format": subscription[5] } exported_subscriptions.append(subscription_obj) base_path = fetch_value("config", {"option_name": "base_location"}, ["option_value"], True) @@ -484,10 +506,20 @@ def import_subscriptions(path="./", delelte_current_subscriptions=False): error_raised = False failed_imports = [] for subscription in subscriptions: + try: + if subscription["output_format"] is not None and len(subscription["output_format"]) > 0: + format_list = json.loads(subscription["output_format"]) + else: + format_list = None + except json.JSONDecodeError: + logger.error("Error while inserting output format for subscription! - Use NONE!") + format_list = None + success = add_subscription(subscription["subscription_path"], subscription["downloaded_content_count"], subscription["subscription_last_checked"], - subscription["last_subscription_data"]) + subscription["last_subscription_data"], + format_list) if not success: error_raised = True failed_imports.append(subscription["subscription_name"]) @@ -648,7 +680,7 @@ def create_subscription_url(url:str, scheme:json): return_val["status"] = True return return_val -def get_subscription_data_obj(url:str, downloaded = None, last_checked=None, last_metadata=None): +def get_subscription_data_obj(url:str, downloaded = None, last_checked=None, last_metadata=None, output_format=None): """ Returns a dict containing all information about a subscription (db obj) and also if the url already exist in db @@ -656,7 +688,7 @@ def get_subscription_data_obj(url:str, downloaded = None, last_checked=None, las { "status": False, -> Operation successfull? - Use this as probe! "exist_in_db": False, -> Does the subscription already exist? - "obj": { -> The subscription object. This can directly passed to SQL Engine + "obj": { -> The subscription object. This can directly passed to the SQL Engine "scheme": None, -> Which scheme is used "subscription_name": None, -> friendly name - most likly playlist/channel name "subscription_path": None, -> function created url to the website @@ -682,7 +714,8 @@ def get_subscription_data_obj(url:str, downloaded = None, last_checked=None, las "subscription_content_count": None, "current_subscription_data": None, "last_subscription_data": None, - "downloaded_content_count": None + "downloaded_content_count": None, + "output_format": output_format } @@ -726,6 +759,7 @@ def get_subscription_data_obj(url:str, downloaded = None, last_checked=None, las obj["subscription_path"] = subscription_data["formed_subscription_url"] obj["subscription_content_count"] = metadata["playlist_count"] obj["current_subscription_data"] = metadata + obj["output_format"] = output_format if downloaded is not None and downloaded > 0: obj["downloaded_content_count"] = downloaded @@ -734,7 +768,6 @@ def get_subscription_data_obj(url:str, downloaded = None, last_checked=None, las if last_metadata is not None: obj["last_subscription_data"] = last_metadata - subscription_entry["obj"] = obj entry_in_db = fetch_value("subscriptions", @@ -779,7 +812,7 @@ def fetch_subscription_name(url:str, scheme:json): ################# Download functions -def direct_download_batch(file:str): +def direct_download_batch(file:str, output_format:list[str] = None): """ This function represents the "manual" video download approach but using a batch file You can pass an url and the file will be downlaoded, hashed and registered. @@ -798,7 +831,7 @@ def direct_download_batch(file:str): with open(file, 'r', encoding="UTF-8") as input_file: for line in input_file: line = line.strip() - if not direct_download(line): + if not direct_download(line, output_format): failed = True if not failed: @@ -808,7 +841,7 @@ def direct_download_batch(file:str): return False #This function is called from CLI -def direct_download(url:str, own_file_data:dict=None): +def direct_download(url:str, own_file_data:dict=None, output_format:list[str] = None): """ This function represents the "manual" video download approach You can pass an url and the file will be downlaoded, hashed and registered. @@ -820,13 +853,15 @@ def direct_download(url:str, own_file_data:dict=None): #Line Break for Pylint #C0301 logger.info("""Directly download content from %s - Check prerequisites and prepare download data""", url) + + if output_format is not None: + logging.debug("Try to use passed format %s", output_format) if not own_file_data: prepared_data = prepare_scheme_dst_data(url) else: prepared_data = own_file_data - if prepared_data["status"] != 1: logger.error("Error while preparing download! - Check log.") return False @@ -835,7 +870,7 @@ def direct_download(url:str, own_file_data:dict=None): logger.info("File will be saved under: %s", path) - downloaded = download_file(url, path) + downloaded = download_file(url=url, path=path, output_format=output_format) if not downloaded["status"]: logger.error("Error while downloading file from %s - Please check log!", url) @@ -876,7 +911,7 @@ def direct_download(url:str, own_file_data:dict=None): return True #This function will actually download a file... -def download_file(url, path, metadata=None, ignore_existing_url=False): +def download_file(url, path, metadata=None, ignore_existing_url=False, output_format:list[str] = None): """This function downloads the file specified in url and also provides the prepared file path from ydl @@ -892,33 +927,39 @@ def download_file(url, path, metadata=None, ignore_existing_url=False): } """ return_val = {"status": False, "full_file_path": None, "filename": None, "metadata": None} - metadata = get_metadata(url, get_ydl_opts(path)) + metadata = get_metadata(url, get_ydl_opts(path, None, output_format)) if metadata is None: logging.error("Error while fetching metadata to check if video already exists in db! - Continue without checking") return return_val - full_file_path = YoutubeDL(get_ydl_opts(path)).prepare_filename(metadata, + + full_file_path = YoutubeDL(get_ydl_opts(path, None, output_format)).prepare_filename(metadata, outtmpl=path + '/%(title)s.%(ext)s') filename = os.path.basename(full_file_path).split(os.path.sep)[-1] return_val["full_file_path"] = full_file_path return_val["filename"] = filename + if not ignore_existing_url: #Check if video (path) is in db - + logger.debug("Check if file already exists in db") + file_in_db = fetch_value("items", {"file_path": path, "file_name": filename}, ["file_path"], True) if file_in_db is not None: logging.info("Video already exists in DB! - check if url exist") + url_is_in_db = check_is_url_in_items_db(url, filename, path) if not url_is_in_db["status"]: logger.error("Error while checking if url is in db!") + #Since the file already exist there is no really need to download the file again. The url add is only a double check. # So we will return true return_val["status"] = True return return_val if not url_is_in_db["url_exist"]: logger.debug("File is already in DB (name match) but url is not the same. Add url to entry!") + url_added = add_url_to_item_is_db(url_is_in_db["id"], url) if not url_added: logger.error("Error while adding url to file in DB!") @@ -927,13 +968,15 @@ def download_file(url, path, metadata=None, ignore_existing_url=False): return_val["status"] = True return return_val - logging.info("File %s dont exist in DB", full_file_path) + + + logger.info("File %s dont exist in DB", full_file_path) logger.info("Downloading file from server") try: - ydl_opts = get_ydl_opts(path) + ydl_opts = get_ydl_opts(path, None, output_format) #Fetch metadata if not passed if metadata is None: @@ -988,7 +1031,8 @@ def download_missing(): "downloaded_content_count", "subscription_content_count", "subscription_has_new_data", - "current_subscription_data"], None, "ORDER BY scheme") + "current_subscription_data", + "output_format"], None, "ORDER BY scheme") if not subscriptions: logger.error("Error while fetching subscriptions!") @@ -996,6 +1040,12 @@ def download_missing(): failed_downloads = {} current_subscription = "" for subscription in subscriptions: + try: + output_filter = json.loads(subscription[7]) + except json.JSONDecodeError: + output_filter = None + logger.error("Error while convertig output format attribute of subscription %s to json!", subscription[1]) + downloaded = 0 #Create a new error array for the current subscription failed_downloads[subscription[1]] = [] @@ -1048,7 +1098,7 @@ def download_missing(): failed_downloads[subscription[1]].append(entry["title"]) continue - file_metadata = get_metadata(entry["url"], get_ydl_opts(expected_path)) + file_metadata = get_metadata(entry["url"], get_ydl_opts(expected_path, format_filter=output_filter)) if file_metadata is None: logger.error("Error while fetching metadata! - Skip item %s", entry["title"]) @@ -1116,7 +1166,7 @@ def download_missing(): if not download_file_now: continue - file_downloaded = direct_download(entry["url"], subscription_path) + file_downloaded = direct_download(entry["url"], subscription_path, output_format=output_filter) if not file_downloaded: #Append to the current subscription error log @@ -1365,6 +1415,7 @@ def scheme_setup(): continue table_exists = check_table_exist(scheme_data["db"]["table_name"]) + #If the table does not exist - create a new table inside the db if not table_exists: result = create_table(scheme_data["db"]["table_name"], scheme_data["db"]["columns"]) @@ -1378,14 +1429,31 @@ def scheme_setup(): #Line Break for Pylint #C0301 logger.info("""Table %s for scheme %s successfully created!""", scheme_data["db"]["table_name"], scheme) - #If table is created check if there are any default values and add these - if "rows" in scheme_data["db"]: - logger.info("""Found default values for scheme %s - - Insert into table""", - scheme) - for option in scheme_data["db"]["rows"]: - #Iterate over all default options and insert them to the config - #table + else: + #If the table already exist check if all columns are created (only adding) + #Check if table is like the scheme (minimal requirements are columns inside the scheme file.) + logger.debug("Check if table %s have all columns...", scheme_data["db"]["table_name"]) + all_columns_exist:bool = check_scheme_match(scheme_data["db"]["table_name"], + scheme_data["db"]["columns"]) + if not all_columns_exist: + logger.error("Error while checking all tables if they have all columns needed! - Check log") + error_occured = True + + #After all tables are created and have the most actual format (all columns) + #Check if the tablee does have any rows that are created by default + #If table is created check if there are any default values and add these + if "rows" in scheme_data["db"] and "row_exist_value" in scheme_data["db"]: + logger.info("""Found default values for scheme %s - + Insert into table""", + scheme) + for option in scheme_data["db"]["rows"]: + #Iterate over all default options and insert them to the config + #table + #Check if the value already exist + print(option[scheme_data["db"]["row_exist_value"]]) + value_already_esist = fetch_value(scheme_data["db"]["table_name"], {scheme_data["db"]["row_exist_value"]: option[scheme_data["db"]["row_exist_value"]]}, [scheme_data["db"]["row_exist_value"]], True ) + + if value_already_esist is None: row_inserted = insert_value( scheme_data["db"]["table_name"], option) @@ -1393,9 +1461,9 @@ def scheme_setup(): logger.error("Error while inserting row: %s!", option) continue logger.debug("Row inserted") - else: - logger.debug("There are no default rows in scheme %s", scheme) - continue + else: + logger.debug("There are no default rows in scheme %s", scheme) + continue else: logger.debug("Scheme %s does not need a table - SKIP", scheme) continue @@ -1863,6 +1931,117 @@ def validate(rehash=True): logger.error("Affected File: %s, Error: %s", error_files[index], error_message) return False + +################# File Format (output format) stuff + +def check_format_profile_exist(name: str): + """ This function checks if a profile exist with a specified name + + Return Val: + - Bool + -> True: Profile exists + -> False: Error or profile does not exist! + """ + profiles = get_all_format_profiles() + + #Check if we got any result at all... + if profiles is None: + logger.error("No profiles returned!") + return False + + if name in profiles: + return True + return False + +def enable_profile(name:str, disable_all_others:bool = False): + """ This function enabled a specified format profile""" + if check_format_profile_exist(name=name): + if disable_all_others: + all_disabled:bool = update_value("format_profiles", {"enabled": 0}, {"enabled": 1}) + if not all_disabled: + logging.error("Error while disabling format profiles! - Abort enabling profile %s", name) + return False + + profile_enabled = update_value("format_profiles", {"enabled": 1}, {"profile_name": name}) + if not profile_enabled: + logging.error("Error while enabling profile %s!", name) + return False + else: + return True + else: + logging.error("Specified profile %s cant be enabled. It does not exist!") + return False + +def disable_profile(name:str): + """ This function disables a specified format profile""" + if check_format_profile_exist(name=name): + + profile_disabled = update_value("format_profiles", {"enabled": 0}, {"profile_name": name}) + if not profile_disabled: + logging.error("Error while enabling profile %s!", name) + return False + else: + return True + else: + logging.error("Specified profile %s cant be disabled. It does not exist!") + return False + +def get_all_format_profiles(only_names = True) -> list|dict: + """ This function returns a list with all profiles currently defined """ + if only_names: + profiles = fetch_value("format_profiles", None, ["profile_name"]) + + profile_list:list = [] + + if profiles is None: + return [] + #load profiles into an json object + try: + for profile in profiles: + profile_list.append(profile[0]) + return profile_list + + except IndexError as e: + logger.error("Error while parsing profile formats! - Error: %s", e) + return [] + else: + profiles = fetch_value("format_profiles", None, ["profile_name", "enabled", "comment", "use_raw", "format", "options", "raw"]) + + profile_list:json = {} + + if profiles is None: + return [] + #load profiles into an json object + try: + for profile in profiles: + profile_list[profile[0]] = { + "profile_name": profile[0], + "enabled": profile[1], + "comment": profile[2], + "use_raw": profile[3], + "format": profile[4], + "options": profile[5], + "raw": profile[6] + } + return profile_list + + except IndexError as e: + logger.error("Error while parsing profile formats! - Error: %s", e) + return [] + +def show_profiles(): + """ This function shows available profiles""" + profiles = get_all_format_profiles(only_names=False) + + profiles_table = PrettyTable(['Profile', 'enabled', 'description']) + profiles_table.align['Profile'] = "l" + profiles_table.align['enabled'] = "c" + profiles_table.align['description'] = "l" + for profile in profiles: + profiles_table.add_row([profiles[profile]["profile_name"], profiles[profile]["enabled"], profiles[profile]["comment"]]) + print(profiles_table) + return True + ################# Helper def fetch_path_data(path): ''' @@ -2235,7 +2414,7 @@ def get_metadata(url, ydl_opts): logger.error("Error while fetching metadata! - Type Error: %s", e) return None -def get_ydl_opts(path, addons:json=None): +def get_ydl_opts(path, addons:json=None, format_filter:list[str] = None): """ #The standards options for yt dlp. These can be modified if the parameter addons is passed. @@ -2247,8 +2426,193 @@ def get_ydl_opts(path, addons:json=None): Return Value: dict - Youtube DLP opts dict """ + #This inline function is used to return the fileformat the user wants to have. + #Optional: You can pass a filter with defined profile(names) to download titles with a specific format profile (list). + #If no filter is passed all enabled formats will be downloaded. + #If a filter is passed it is not important if the format is enabled or not. The filter overrules + ''' + def get_ytdlp_format(format_filter:list[str] = None): + + returned_format_list:list = [] + returned_format:str = "" + #Just for logging... + returned_format_profiles:str = "" + + fallback_format = config.get("other", "fallback_format") + + if fallback_format == "": + fallback_format = "best" + + #If a filter is passed - just in case lower all profiles + if format_filter is not None: + format_filter = [filter_value.lower() for filter_value in format_filter] + + logger.debug("Format filter is passed. Try to use passed filters") + else: + logger.debug("No format Filter passed. Use all applicable formats") + + try: + #Fetch format data from db. Normally a json array is returned (expected) -> e.g + #[ + # { + # "enabled": false, -> This format will not be used but the profile exists + # "use_raw": false, + # "format": "mp4", + # "height": "1080", + # "raw": "" + # }, + # { + # "enabled": true, + # "format": "best" + # } + #] + logger.debug("Fetch needed formats from config") + format_data = fetch_value("config", {"option_name": "format_profiles"}, ["option_value"], True) + #Check if a valid json array is returned + format_data = format_data[0] + #Convert readed string to valid json array + try: + format_data = json.loads(format_data) + + if not isinstance(format_data, list) or len(format_data) == 0: + logger.warning("No format is defined! - Default value (%s) will be returned!", fallback_format) + returned_format = fallback_format + else: + #Iterate over the list and fetch all needed formats + logger.debug("Iterate over %i formats", len(format_data)) + for output_format in format_data: + output_format_build_str:str = "" + #Check if a filter is passed. If a filter is passed check if the current profile name is included. If not go to the next one... + #Check if the profile is enabled and a profile_name is passed (normal way) + if (("enabled" in output_format and output_format["enabled"] is True and "profile_name" in output_format and format_filter is None) or + #check if the profilename exist and a format_filter is passed (it is not relevant if the profile is enabled) and check if the current profile is in the filter list + ("profile_name" in output_format and format_filter is not None and isinstance(format_filter, list) and str(output_format["profile_name"]).lower() in format_filter)): + + logger.debug("Use format profile %s", output_format["profile_name"]) + #Only accept formats with "enabled" key and enabled = true (format is supposed to be used) and a profile name + if "use_raw" in output_format and output_format["use_raw"] is True: + if "raw" in output_format and len(output_format["raw"]) > 0: + #If "use_raw" is enabled all other options are menaingless. Just paste the raw data to the format string + output_format_build_str = output_format["raw"] + else: + logger.warning("RAW format is skipped! - Not a valid format entry!") + else: + #If not use_raw inside the object -> Paste data... - Check if all needed keys are existing + if "format" in output_format and isinstance(output_format["format"], str) and len(output_format["format"]) > 0: + output_format_build_str:str = output_format["format"] + if "options" in output_format and isinstance(output_format["options"], list) and len(output_format["options"]) > 0: + format_options:str = "[" + for format_option in output_format["options"]: + format_options += format_option + format_options += "," + format_options = format_options[:-1] + format_options += "]" + output_format_build_str += format_options + #Append the current format (string) to the output format list + returned_format_list.append(output_format_build_str) + #Append profile name for logging + returned_format_profiles += "'" + output_format["profile_name"] + "' " + else: + logger.debug("Skipped format") + + #Convert the list to a string + returned_format = ",".join(returned_format_list[::-1]) + + except json.JSONDecodeError as e: + logger.error("Error while converting readed format data to json array! - Return default value - Error: %s", e) + returned_format = fallback_format + except (ValueError, TypeError) as e: + logger.error("Error while fetching valid information format information from db! - Return default format (%s). - Error: %s", fallback_format, e) + returned_format = fallback_format + + if returned_format != "": + logger.info("Used formats: %s", returned_format_profiles) + return returned_format + else: + logger.warning("Leak! -> returned format does not have a valid format!") + return fallback_format + ''' + + #New Version using DB + def get_ytdlp_format(format_filter:list[str] = None): + """ This function is used to return all needed formats for the current operation""" + + #This is the fallback format in case we dont find any profiles + fallback_format = config.get("other", "fallback_format") + + if fallback_format == "": + fallback_format = "best" + + final_list:list = [] + + if format_filter is not None: + #Only check if all values inside the list (format_filter) are real profiles. All real values are directly passed with the corresponding options + for desired_format in format_filter: + if check_format_profile_exist(desired_format): + final_list.append(desired_format) + else: + enabled_profiles = fetch_value("format_profiles", {"enabled": "1"}, ["profile_name"]) + + if enabled_profiles is None: + logging.warning("No format profile is enabled! - Use fallback 'best'!") + return fallback_format + + for enabled_profile in enabled_profiles: + final_list.append(enabled_profile[0]) + + #Now we have all formats we want for our file... No matter if we use only enabled or a predefined set! + + if len(final_list) == 0: + #If we dont have any profiles now - return fallback + return fallback_format + + returned_format_list:list = [] + + profiles = get_all_format_profiles(only_names=False) + + for final_format in final_list: + #The cache string for our current format + output_format_build_str:str = "" + #We need to iterate over the list of formats used for our file and then create the string based on the options + #Fetch the current profile + try: + current_profile = profiles[final_format] + + if "use_raw" in current_profile and current_profile["use_raw"] == 1: + #If the current profile uses RAW input - we only need to append the raw field to the return str + if "raw" in current_profile and current_profile["raw"] is not None and len(current_profile["raw"]) > 0: + output_format_build_str = current_profile["raw"] + else: + logger.warning("RAW format is skipped! - Not a valid format entry!") + continue + else: + #We dont use raw... we need to build it manually + if "format" in current_profile and current_profile["format"] is not None and len(current_profile["format"]) > 0: + output_format_build_str:str = current_profile["format"] + if "options" in current_profile and current_profile["options"] is not None and len(current_profile["options"]) > 0: + output_format_build_str += current_profile["options"] + #Append the current format (string) to the output format list + returned_format_list.append(output_format_build_str) + except KeyError as e: + logging.error("Error while fetching profile for %s. Skip format! - Error: %s", final_format, e) + continue + + + if len(returned_format_list) == 0: + #If we don't have any formats in this list, something went wrong... Use fallback + return fallback_format + #Convert the list to a string + returned_format = ",".join(returned_format_list[::-1]) + + if returned_format != "": + logger.info("Used formats: %s", final_list) + return returned_format + else: + logger.warning("Leak! -> returned format does not have a valid format!") + return fallback_format + opts = { - 'format': 'best', + 'format': get_ytdlp_format(format_filter), 'outtmpl': path + '/%(title)s.%(ext)s', 'nooverwrites': True, 'no_warnings': True, @@ -2256,13 +2620,12 @@ def get_ydl_opts(path, addons:json=None): 'replace-in-metadata': True, 'restrict-filenames': True } - if addons is None: - #Return default set - return opts - for key in addons: - if key == "outtmpl": - continue - opts[key] = addons[key] + if addons is not None: + for key in addons: + if key == "outtmpl": + continue + opts[key] = addons[key] + return opts def create_hash_from_file(file): @@ -2513,7 +2876,7 @@ def add_duplicate_file(hash_value, c_filename, c_filepath= None, db_id=None, db_ content = file.read() duplicates_json = {} - if content is not None and content is not "": + if content is not None and content != "": try: duplicates_json = json.loads(content) except json.JSONDecodeError: @@ -2594,7 +2957,7 @@ def show_duplicate_files(): if content is None or len(content) < 2: logging.info("No duplicates found!") return None - if content is not None and content is not "": + if content is not None and content != "": try: duplicates_json = json.loads(content) except json.JSONDecodeError: diff --git a/requirements.txt b/requirements.txt index dba59e6..eda4b46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ requests >= 2.3.1 tldextract >= 5.1.2 prettytable >= 3.10.0 -yt_dlp >= 2024.4.9 +yt_dlp >= 2024.8.6 validators >= 0.28.1 pytz >= 2024.1 diff --git a/scheme/formats.json b/scheme/formats.json new file mode 100644 index 0000000..46cc6d0 --- /dev/null +++ b/scheme/formats.json @@ -0,0 +1,74 @@ +{ + "schema_name": "format_profiles", + "url_template": false, + "db": { + "table_needed": true, + "table_name": "format_profiles", + "columns": { + "id": {"type": "integer", "primary_key": true, "auto_increment": true, "not_null": true, "unique": false}, + "profile_name": {"type": "text", "not_null": true, "unique": true}, + "enabled": {"type": "integer", "not_null": true, "default": "0"}, + "datecreated": {"type": "DATETIME", "default": "CURRENT_TIMESTAMP"}, + "comment": {"type": "text", "not_null": false}, + "use_raw": {"type": "integer", "default": "0"}, + "format": {"type": "text", "not_null": true}, + "options": {"type": "text"}, + "raw": {"type": "text"} + + }, + "row_exist_value": "profile_name", + "rows": [ + { + "enabled": "0", + "profile_name": "mp4_1080p", + "comment": "This profile downloads a mp4 with a pixel height of 1080", + "use_raw": "0", + "format": "mp4", + "options": "[\"height=1080\"]", + "raw": "" + }, + { + "enabled": 0, + "profile_name": "mp4_360", + "comment": "This profile downloads a mp4 with a pixel height of 360", + "use_raw": 0, + "format": "mp4", + "options": "[\"height=360\"]", + "raw": "" + }, + { + "enabled": 0, + "profile_name": "mp4_240", + "comment": "This profile downloads a mp4 with a pixel height of 240", + "use_raw": 0, + "format": "mp4", + "options": "[\"height=240\"]", + "raw": "" + }, + { + "enabled": 0, + "profile_name": "opus", + "comment": "This profile will download only opus", + "format": "opus" + }, + { + "enabled": 0, + "profile_name": "m4a", + "comment": "This profile will download only m4a", + "format": "m4a" + }, + { + "enabled": 1, + "profile_name": "best_format", + "comment": "This profile downloads always the best possible format (Default)", + "format": "best" + }, + { + "enabled": 0, + "profile_name": "best_audio", + "comment": "This profile downloads always the best possible audio format", + "format": "best_audio" + } + ] + } +} \ No newline at end of file diff --git a/scheme/project.json b/scheme/project.json index 300017e..60118fe 100644 --- a/scheme/project.json +++ b/scheme/project.json @@ -10,6 +10,7 @@ "option_value": {"type": "text", "not_null": true}, "datecreated": {"type": "DATETIME", "default": "CURRENT_TIMESTAMP"} }, + "row_exist_value": "option_name", "rows": [ {"option_name": "base_location", "option_value": "./ytdownloader"}, {"option_name": "use_tags_from_ydl", "option_value": "false"}, diff --git a/scheme/subscriptions.json b/scheme/subscriptions.json index 8bf4da6..0048bdc 100644 --- a/scheme/subscriptions.json +++ b/scheme/subscriptions.json @@ -15,7 +15,8 @@ "subscription_content_count": {"type": "integer", "not_null": true}, "subscription_has_new_data": {"type": "integer", "not_null": true, "default": "1"}, "current_subscription_data": {"type": "text", "not_null": true}, - "last_subscription_data": {"type": "text"} + "last_subscription_data": {"type": "text"}, + "output_format": {"type": "text"} } } } \ No newline at end of file diff --git a/test.py b/test.py index 2f259b7..e69de29 100644 --- a/test.py +++ b/test.py @@ -1 +0,0 @@ -s \ No newline at end of file diff --git a/yt_manager.py b/yt_manager.py index 68ba6a9..bb7aa73 100644 --- a/yt_manager.py +++ b/yt_manager.py @@ -23,19 +23,20 @@ # Python Modules import logging import sys -#import argparse +import argparse # Own Modules from project_functions import (show_help, direct_download, direct_download_batch, scheme_setup, add_subscription, add_subscription_batch, del_subscription, list_subscriptions, export_subscriptions, import_subscriptions, start, validate, export_items, import_items, - show_duplicate_files, check_for_workdir) + show_duplicate_files, check_for_workdir, + show_profiles, enable_profile, disable_profile) from database_manager import check_db from config_handler import check_for_config #Version -CURRENT_VERSION = 20240604 +CURRENT_VERSION = 20240921 # Init. Logging @@ -93,95 +94,131 @@ logger.info("Workdir can't be created!") sys.exit(-1) -#Deciding action based on given arguments -if len(sys.argv) > 1: - #Command provided - NO_ERROR = True - match sys.argv[1]: - case "help": - #provide help - show_help() +def convert_to_list(val) -> list[str]: + """ This function is used to convert the user input to a list of strings""" + if isinstance(val, list): + return val + elif isinstance(val, str): + return val.split(",") + raise argparse.ArgumentTypeError(f"Invalid format: {val} . Expected a list or a comma-separated string.") + + + +#CLI + +def main(): + """ The main function provides the CLI""" + parser = argparse.ArgumentParser(description="YT-Download Manager by j54j6") + + # Subcommands + subparsers = parser.add_subparsers(dest="command") + + subparsers.add_parser("help", help="Show help information") + + # Add subscription command + add_sub = subparsers.add_parser("add-subscription", help="Add a new subscription") + add_sub.add_argument("url", help="URL of the subscription") + add_sub.add_argument("--batch", help="Add subscriptions in batch mode", nargs="?", const=True) + add_sub.add_argument( + "--output-format", + help="Specify the output format", + nargs="?", # Optional argument + const=None, # Default to 'NONE' if --output-profile is provided without a value + type=convert_to_list + ) + + # Delete subscription command + del_sub = subparsers.add_parser("del-subscription", help="Delete a subscription") + del_sub.add_argument("url", help="URL of the subscription") + + # List subscriptions command + list_sub = subparsers.add_parser("list-subscriptions", help="List all subscriptions") + list_sub.add_argument("filter", help="Filter for subscription list", nargs="?") + + subparsers.add_parser("export-subscriptions", help="Export all subscriptions") + + import_sub = subparsers.add_parser("import-subscriptions", help="Import subscriptions") + import_sub.add_argument("path", help="Path to the file") + import_sub.add_argument("--overwrite", help="Overwrite existing subscriptions", nargs="?", const=True) + + subparsers.add_parser("export-items", help="Export all items") + + import_items_parser = subparsers.add_parser("import-items", help="Import items") + import_items_parser.add_argument("path", help="Path to the file") + + subparsers.add_parser("backup", help="Create a backup of subscriptions and items") + + custom = subparsers.add_parser("custom", help="Download a custom item") + custom.add_argument("url", help="URL of the item") + custom.add_argument("--batch", help="Download in batch mode", nargs="?", const=True) + custom.add_argument( + "--output-format", + help="Specify the output format", + nargs="?", # Optional argument + const=None, # Default to 'NONE' if --output-profile is provided without a value + type=convert_to_list + ) + + subparsers.add_parser("start", help="Run the script to check for new content and download it") + + subparsers.add_parser("validate", help="Rehash all files and compare them to stored files") + + subparsers.add_parser("show-duplicates", help="Show duplicate files") + + subparsers.add_parser("show-format-profiles", help="Show all currently defined profiles to define the output format") + + en_format_profile = subparsers.add_parser("enable-format-profile", help="Enable a specific format profile (globally)") + en_format_profile.add_argument("profile_name", help="Profilename of the intended profile") + en_format_profile.add_argument("--only_active", help="If this is true, all other profiles will be disabled", const=False, nargs="?") + + dis_format_profile = subparsers.add_parser("disable-format-profile", help="Disable a specific format profile (globally)") + dis_format_profile.add_argument("profile_name", help="Profilename of the intended profile") + # Parse arguments + args = parser.parse_args() + + # Command mapping to functions + commands = { + "help": show_help, + "add-subscription": lambda: ( + add_subscription_batch(file=args.url, output_format=args.output_format) if args.batch else add_subscription(url=args.url, output_format=args.output_format) + ) if args.output_format is not None else ( + add_subscription_batch(args.url) if args.batch else add_subscription(args.url) + ), + "del-subscription": lambda: del_subscription(args.url), + "list-subscriptions": lambda: list_subscriptions(list(args.filter.split(",")) if args.filter else None), + "export-subscriptions": export_subscriptions, + "import-subscriptions": lambda: import_subscriptions(args.path, args.overwrite), + "export-items": export_items, + "import-items": lambda: import_items(args.path), + "backup": lambda: export_subscriptions() and export_items(), + "custom": lambda: ( + direct_download_batch(args.url, args.output_format) if args.batch + else direct_download(args.url, None, args.output_format) + ) if args.output_format is not None else ( + direct_download_batch(args.url) if args.batch + else direct_download(args.url) + ), + "start": start, + "validate": validate, + "show-duplicates": show_duplicate_files, + "show-format-profiles": show_profiles, + "enable-format-profile": lambda: enable_profile(args.profile_name, args.only_active), + "disable-format-profile": lambda: disable_profile(args.profile_name), + } + + # Execute the command + command_func = commands.get(args.command) + if command_func: + NO_ERROR = command_func() + if NO_ERROR: + sys.exit(0) + else: + logging.error("Command execution failed.") sys.exit(1) - case "add-subscription": - #Add a new subscription - if len(sys.argv) >= 3 and len(sys.argv) <= 5: - if str(sys.argv[2]).lower() != "batch": - NO_ERROR = add_subscription(sys.argv[2]) - else: - NO_ERROR = add_subscription_batch(sys.argv[3]) - else: - logging.error("No url provided!") - show_help() - sys.exit(-1) - case "del-subscription": - #Delete a subscription - if len(sys.argv) == 3: - NO_ERROR = del_subscription(sys.argv[2]) - else: - logging.error("No url provided!") - show_help() - sys.exit(-1) - case "list-subscriptions": - #Show all subscriptions - if len(sys.argv) == 3: - filter_list = list(sys.argv[2].split(",")) - NO_ERROR = list_subscriptions(filter_list) - else: - NO_ERROR = list_subscriptions(None) - case "export-subscriptions": - NO_ERROR = export_subscriptions() - case "import-subscriptions": - if sys.argv[2] is not None and sys.argv[3] is None: - NO_ERROR = import_subscriptions(sys.argv[2]) - elif sys.argv[2] is not None and sys.argv[3] is not None: - if sys.argv[3] == "True": - NO_ERROR = import_subscriptions(sys.argv[2], True) - else: - NO_ERROR = import_subscriptions(sys.argv[2]) - else: - logging.error("Please provide a path to import subscriptions") - show_help() - sys.exit(-1) - case "export-items": - NO_ERROR = export_items() - case "import-items": - if sys.argv[2] is not None: - NO_ERROR = import_items(sys.argv[2]) - else: - logging.error("Please provide a path to import items") - show_help() - sys.exit(-1) - case "backup": - NO_ERROR = export_subscriptions() - if NO_ERROR: - NO_ERROR = export_items() - logging.info("Backup successfully created") - case "custom": - #Download a custom Item without being part of a subscription - #parser = argparse - if len(sys.argv) >= 3 and len(sys.argv) <= 4: - if str(sys.argv[2]).lower() != "batch": - NO_ERROR = direct_download(sys.argv[2]) - else: - NO_ERROR = direct_download_batch(sys.argv[3]) - else: - logging.error("No url provided!") - show_help() - sys.exit(-1) - case "start": - #Run the script to check for new content and download it - NO_ERROR = start() - case "validate": - #Rehash all files and compare them to the already stored files. And look for files not registered in the db - NO_ERROR = validate() - case "show-duplicates": - show_duplicate_files() - case _: - show_help() - sys.exit(-1) - if NO_ERROR: - sys.exit(0) - sys.exit(-1) -else: - show_help() - sys.exit(-1) + else: + logging.error("Invalid command. Showing help.") + show_help() + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/ytdownloader/subscriptions_export.json b/ytdownloader/subscriptions_export.json new file mode 100644 index 0000000..d37048f --- /dev/null +++ b/ytdownloader/subscriptions_export.json @@ -0,0 +1 @@ +[{"subscription_path": "https://www.youtube.com/@homedecorideas3017/videos", "subscription_last_checked": "2024-09-22 18:39:16", "downloaded_content_count": 0, "last_subscription_data": null, "subscription_name": "@homedecorideas3017", "output_format": ""}] \ No newline at end of file