Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the default be off?

Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
TORBOX_API_KEY=
MOUNT_METHOD=strm
MOUNT_PATH=/torbox
MOUNT_PATH=/torbox
SYMLINK_PATH=/symlinks
SYMLINK_CREATION=always
DEBUG_MODE=false
5 changes: 5 additions & 0 deletions README.md
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you write about the benefits of using symlinking with FUSE?

Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 27 additions & 1 deletion functions/appFunctions.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -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 = []
Expand Down Expand Up @@ -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
Expand All @@ -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
21 changes: 20 additions & 1 deletion functions/databaseFunctions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from tinydb import TinyDB
from tinydb import TinyDB, Query
import threading
import logging

Expand Down Expand Up @@ -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.
Expand Down
85 changes: 82 additions & 3 deletions functions/fuseFilesystemFunctions.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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
import time
import sys
import logging
from functions.appFunctions import getAllUserDownloads
from functions.databaseFunctions import insertData, getAllData, deleteData
import threading
from sys import platform

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping the debug would be helpful

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):
Expand Down Expand Up @@ -255,4 +311,27 @@ def unmountFuse():
except OSError as e:
logging.error(f"Error unmounting: {e}")
sys.exit(1)
logging.info("Unmounted successfully.")
logging.info("Unmounted successfully.")


def create_symlink_in_symlink_path(vfs_path, symlink_path):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use camelcase

# 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}")
4 changes: 3 additions & 1 deletion functions/torboxFunctions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion library/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
MOUNT_REFRESH_TIME = MountRefreshTimes[MOUNT_REFRESH_TIME].value

DEBUG_MODE = os.getenv("DEBUG_MODE", False) in [True,'true']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should just wrap in a bool to convert it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure it works like this, but needs testing

12 changes: 11 additions & 1 deletion library/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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"
4 changes: 3 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
Expand Down