diff --git a/.env.example b/.env.example index 2286029..18bad5b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ TORBOX_API_KEY= MOUNT_METHOD=strm -MOUNT_PATH=/torbox \ No newline at end of file +MOUNT_PATH=/torbox +SYMLINK_PATH=/symlinks +SYMLINK_CREATION=always +DEBUG_MODE=false diff --git a/README.md b/README.md index bd1bbea..976be79 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,11 @@ To run this project you will need to add the following environment variables to `MOUNT_REFRESH_TIME` How fast you would like your mount to look for new files. Must be either `slow` for every 3 hours, `normal` for every 2 hours, `fast` for every 1 hour, or `instant` for every 6 minutes. The default is `fast` and is optional. +`SYMLINK_PATH` The path where symlinks to your files should be created if using `MOUNT_METHOD` of `fuse`. If inside of Docker, this path needs to be accessible to other applications. If running locally without Docker, this path must be owned. Setting is optional, omit to skip symlink creation. + +`SYMLINK_CREATION` When the symlinks should be created. Must be either `once`, `spawn` or `always`. `always` will create them each time the mount is refreshed, `spawn` will create them once per session or the first time the file is created in the mount path after the app starts, `once` will create them one-time only. The default is `always` and is optional. + + ## 🐳 Running on Docker with one command (recommended) We provide bash scripts for running the TorBox Media Center easily by simply copying the script to your server or computer, and running it, following the prompts. This can be helpful if you aren't familiar with Docker, permissions or servers in general. Simply choose one in [this folder](https://github.com/TorBox-App/torbox-media-center/blob/main/scripts) that pertains to your system and run it in the terminal. diff --git a/functions/appFunctions.py b/functions/appFunctions.py index f2d0d2b..a49fda3 100644 --- a/functions/appFunctions.py +++ b/functions/appFunctions.py @@ -1,5 +1,5 @@ from functions.torboxFunctions import getUserDownloads, DownloadType -from library.filesystem import MOUNT_METHOD, MOUNT_PATH +from library.filesystem import MOUNT_METHOD, MOUNT_PATH, SYMLINK_PATH, SYMLINK_CREATION from library.app import MOUNT_REFRESH_TIME from library.torbox import TORBOX_API_KEY from functions.databaseFunctions import getAllData, clearDatabase @@ -16,6 +16,12 @@ def initializeFolders(): os.path.join(MOUNT_PATH, "movies"), os.path.join(MOUNT_PATH, "series"), ] + if SYMLINK_PATH: + symfolders = [ + SYMLINK_PATH, + os.path.join(SYMLINK_PATH, "movies"), + os.path.join(SYMLINK_PATH, "series"), + ] for folder in folders: if os.path.exists(folder): @@ -30,6 +36,13 @@ def initializeFolders(): logging.debug(f"Creating folder {folder}...") os.makedirs(folder, exist_ok=True) + for folder in symfolders: + if os.path.exists(folder): + logging.debug(f"Folder {folder} already exists...") + else: + logging.debug(f"Creating folder {folder}...") + os.makedirs(folder, exist_ok=True) + def getAllUserDownloadsFresh(): all_downloads = [] @@ -68,8 +81,15 @@ def bootUp(): logging.debug("Booting up...") logging.info("Mount method: %s", MOUNT_METHOD) logging.info("Mount path: %s", MOUNT_PATH) + logging.info("Symlink Path: %s", SYMLINK_PATH) + logging.info("Symlink Creation Method: %s", SYMLINK_CREATION) logging.info("TorBox API Key: %s", TORBOX_API_KEY) logging.info("Mount refresh time: %s %s", MOUNT_REFRESH_TIME, "hours") + if SYMLINK_CREATION != 'once': + try: + os.remove(f"{os.curdir}/symlinks.json") + except Exception as e: + logging.debug("symlinks database not yet created") initializeFolders() return True @@ -80,5 +100,11 @@ def getMountMethod(): def getMountPath(): return MOUNT_PATH +def getSymPath(): + return SYMLINK_PATH + +def getSymCreation(): + return SYMLINK_CREATION + def getMountRefreshTime(): return MOUNT_REFRESH_TIME \ No newline at end of file diff --git a/functions/databaseFunctions.py b/functions/databaseFunctions.py index 186777e..4107be2 100644 --- a/functions/databaseFunctions.py +++ b/functions/databaseFunctions.py @@ -1,4 +1,4 @@ -from tinydb import TinyDB +from tinydb import TinyDB, Query import threading import logging @@ -67,6 +67,25 @@ def insertData(data: dict, type: str): except Exception as e: return False, f"Error inserting data. {e}" + +def deleteData(data: dict, type: str): + """ + Deletes data from the database with thread safety. + """ + db = getDatabase(type) + db_lock = getDatabaseLock(type) + + if db is None or db_lock is None: + return False, "Database connection failed." + + with db_lock: + rem_query = Query() + try: + db.remove(rem_query.item_id == data.get('item_id',None)) + return True, "Data removed successfully." + except Exception as e: + return False, f"Error removing data. {e}" + def getAllData(type: str): """ Retrieves all data from the database with thread safety. diff --git a/functions/fuseFilesystemFunctions.py b/functions/fuseFilesystemFunctions.py index 6a64d9b..d505821 100644 --- a/functions/fuseFilesystemFunctions.py +++ b/functions/fuseFilesystemFunctions.py @@ -1,5 +1,5 @@ import os -from library.filesystem import MOUNT_PATH +from library.filesystem import MOUNT_PATH, SYMLINK_PATH, SYMLINK_CREATION import stat import errno from functions.torboxFunctions import getDownloadLink, downloadFile @@ -7,6 +7,7 @@ import sys import logging from functions.appFunctions import getAllUserDownloads +from functions.databaseFunctions import insertData, getAllData, deleteData import threading from sys import platform @@ -92,6 +93,9 @@ def get_file(self, path): def list_dir(self, path): return self.structure.get(path, []) + + + class FuseStat(fuse.Stat): def __init__(self): self.st_mode = 0 @@ -122,12 +126,64 @@ def __init__(self, *args, **kwargs): self.max_blocks = 16 def getFiles(self): + prev_files = [] while True: files = getAllUserDownloads() if files: self.files = files self.vfs = VirtualFileSystem(self.files) - logging.debug(f"Updated {len(self.files)} files in VFS") + logging.info(f"Updated {len(self.files)} files in VFS") + if SYMLINK_PATH: + try: + get_symlink_data = getAllData('symlinks')[0] + except: + get_symlink_data = [] + # logging.debug(f"Symlink db:\n{get_symlink_data}") + for file_item in files: + symlink_record = file_item + if file_item.get('metadata_mediatype') == 'movie': + path_tail = f"movies/{file_item.get('metadata_rootfoldername')}/{file_item.get('metadata_filename')}" + else: + path_tail = f"series/{file_item.get('metadata_rootfoldername')}/{file_item.get('metadata_foldername')}/{file_item.get('metadata_filename')}" + v_path = f"{MOUNT_PATH}/{path_tail}" + s_path = f"{SYMLINK_PATH}/{path_tail}" + symlink_record['real_path'] = v_path + symlink_record['symlink_path'] = s_path + if isinstance(get_symlink_data,list) and len(get_symlink_data) > 0: + exists = any(d.get("symlink_path",None) == s_path for d in get_symlink_data) + else: + exists = False + if exists == False or SYMLINK_CREATION == 'always': + logging.debug(f"Attempting to symlink {v_path} to {s_path}") + create_symlink_in_symlink_path(v_path, s_path) + insertData(symlink_record,'symlinks') + else: + logging.debug(f"Symlink {s_path} created previously and creation set to '{SYMLINK_CREATION}'. Skipping") + + logging.info(f"Updated {len(files)} symlinks") + deleted_files = list({doc.get('item_id') for doc in prev_files} - {doc.get('item_id') for doc in files}) + if deleted_files and SYMLINK_PATH: + for file_item in deleted_files: + symlink_record = file_item + if file_item.get('metadata_mediatype') == 'movie': + s_path = f"{SYMLINK_PATH}/movies/{file_item.get('metadata_rootfoldername')}/{file_item.get('metadata_filename')}" + else: + s_path = f"{SYMLINK_PATH}/series/{file_item.get('metadata_rootfoldername')}/{file_item.get('metadata_foldername')}/{file_item.get('metadata_filename')}" + symlink_record['symlink_path'] = s_path + deleteData(symlink_record,'symlinks') + if os.path.islink(s_path): + try: + os.unlink(s_path) + logging.debug(f"Removed symlink {s_path}") + except Exception as e: + logging.error(f"Cannot remove symlink {s_path}: {e}") + pass + else: + logging.debug(f"Symlink {s_path} does not exist") + logging.info(f"Removed {len(deleted_files)} broken or dead symlinks") + + prev_files = files + logging.debug(f"Waiting 5mins before querying Torbox again for changes") time.sleep(300) def getattr(self, path): @@ -255,4 +311,27 @@ def unmountFuse(): except OSError as e: logging.error(f"Error unmounting: {e}") sys.exit(1) - logging.info("Unmounted successfully.") \ No newline at end of file + logging.info("Unmounted successfully.") + + +def create_symlink_in_symlink_path(vfs_path, symlink_path): + # vfs_path: the path inside the FUSE mount (e.g., /mnt/torbox_media/movies/Foo (2024)/Foo (2024).mkv) + # symlink_path: the desired symlink location (e.g., /home/youruser/symlinks/Foo (2024).mkv) + try: + path_split = str(symlink_path).split('/') + path_split = path_split[:-1] + path_split = [p for p in path_split if p] + path_joined = '' + for folder in path_split: + path_joined = f'{path_joined}/{folder}' + if os.path.exists(path_joined) == False: + logging.debug(f"Creating folder {path_joined}...") + os.makedirs(path_joined, exist_ok=True) + + if os.path.exists(symlink_path) or os.path.islink(symlink_path): + logging.debug(f"Removing existing symlink {symlink_path}") + os.remove(symlink_path) + os.symlink(vfs_path, symlink_path) + logging.debug(f"Symlinked {vfs_path} -> {symlink_path}") + except Exception as e: + logging.error(f"Error creating symlink: {e}") diff --git a/functions/torboxFunctions.py b/functions/torboxFunctions.py index bb5bcd8..8318ce1 100644 --- a/functions/torboxFunctions.py +++ b/functions/torboxFunctions.py @@ -3,6 +3,7 @@ from enum import Enum import PTN from library.torbox import TORBOX_API_KEY +from library.filesystem import MOUNT_PATH, SYMLINK_PATH from functions.mediaFunctions import constructSeriesTitle, cleanTitle, cleanYear from functions.databaseFunctions import insertData import os @@ -52,7 +53,7 @@ def process_file(item, file, type): metadata, _, _ = searchMetadata(title_data.get("title", file.get("short_name")), title_data, file.get("short_name"), f"{item.get('name')} {file.get('short_name')}") data.update(metadata) - logging.debug(data) + logging.debug(f"Processing data {data}") insertData(data, type.value) return data @@ -114,6 +115,7 @@ def getUserDownloads(type: DownloadType): for future in as_completed(future_to_file): try: data = future.result() + logging.debug(f"Future result data: {data}") if data: files.append(data) except Exception as e: diff --git a/library/app.py b/library/app.py index e36416c..6c29c5c 100644 --- a/library/app.py +++ b/library/app.py @@ -15,4 +15,6 @@ class MountRefreshTimes(Enum): MOUNT_REFRESH_TIME = MOUNT_REFRESH_TIME.lower() assert MOUNT_REFRESH_TIME in [e.name for e in MountRefreshTimes], f"Invalid mount refresh time: {MOUNT_REFRESH_TIME}. Valid options are: {[e.name for e in MountRefreshTimes]}" -MOUNT_REFRESH_TIME = MountRefreshTimes[MOUNT_REFRESH_TIME].value \ No newline at end of file +MOUNT_REFRESH_TIME = MountRefreshTimes[MOUNT_REFRESH_TIME].value + +DEBUG_MODE = os.getenv("DEBUG_MODE", False) in [True,'true'] diff --git a/library/filesystem.py b/library/filesystem.py index cfb8f6d..ca5ee06 100644 --- a/library/filesystem.py +++ b/library/filesystem.py @@ -8,8 +8,18 @@ class MountMethods(Enum): strm = "strm" fuse = "fuse" +class SymlinkCreation(Enum): + once = "once" + spawn = "spawn" + always = "always" + MOUNT_METHOD = os.getenv("MOUNT_METHOD", MountMethods.strm.value) assert MOUNT_METHOD in [method.value for method in MountMethods], "MOUNT_METHOD is not set correctly in .env file" MOUNT_PATH = os.getenv("MOUNT_PATH", "./torbox") -assert MOUNT_PATH, "MOUNT_PATH is not set in .env file" \ No newline at end of file +assert MOUNT_PATH, "MOUNT_PATH is not set in .env file" + +SYMLINK_PATH = os.getenv("SYMLINK_PATH", None) + +SYMLINK_CREATION = os.getenv("SYMLINK_CREATION", "always") +assert SYMLINK_CREATION in [symlink.value for symlink in SymlinkCreation], "SYMLINK_CREATION is not set correctly in .env file" diff --git a/main.py b/main.py index 6555858..27bb3a3 100644 --- a/main.py +++ b/main.py @@ -2,11 +2,13 @@ from apscheduler.schedulers.background import BackgroundScheduler from functions.appFunctions import bootUp, getMountMethod, getAllUserDownloadsFresh, getMountRefreshTime from functions.databaseFunctions import closeAllDatabases +from library.app import DEBUG_MODE import logging from sys import platform +import os logging.basicConfig( - level=logging.INFO, + level=logging.DEBUG if DEBUG_MODE == True else logging.INFO, format='%(asctime)s,%(msecs)03d %(name)s %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S', )