diff --git a/apps/docker-uploader/Dockerfile b/apps/docker-uploader/Dockerfile
index 4771a30562..896da838f1 100644
--- a/apps/docker-uploader/Dockerfile
+++ b/apps/docker-uploader/Dockerfile
@@ -36,5 +36,9 @@ RUN \
COPY ./apps/docker-uploader/root/ /
+COPY release.json /var/www/html/release.json
+
+RUN chown abc:abc /var/www/html/release.json && chmod 644 /var/www/html/release.json
+
ENTRYPOINT /init
##EOF
diff --git a/apps/docker-uploader/root/app/uploader/function-db.sh b/apps/docker-uploader/root/app/uploader/function-db.sh
index f1de667a7e..900ffe8b28 100755
--- a/apps/docker-uploader/root/app/uploader/function-db.sh
+++ b/apps/docker-uploader/root/app/uploader/function-db.sh
@@ -77,36 +77,78 @@ function autoscan() {
if [[ "${AUTOSCAN_USER}" == "null" ]]; then
$(which curl) -sfG -X POST --data-urlencode "dir=${SUNION}/${DIR}" "${AUTOSCAN_URL}/triggers/manual"
else
- $(which curl) -sfG -X POST -u "${AUTOSCAN_USER}:${AUTOSCAN_PASS}" --data-urlencode "dir=${SUNION}/${DIR}" "${AUTOSCAN_URL}/triggers/manual"
+ #### Fill available transfer slots in this cycle ####
+ LOGGED_CAPACITY=false
+ CAPACITY_FLAG="/tmp/uploader_capacity_logged"
+ while true; do
+ ACTIVETRANSFERS=$(sqlite3read "SELECT COUNT(*) FROM uploads;" 2>/dev/null)
+ source /system/uploader/uploader.env
+ if [[ "${TRANSFERS}" != +([0-9.]) ]] || [ "${TRANSFERS}" -gt "99" ] || [ "${TRANSFERS}" -eq "0" ]; then
+ TRANSFERS="1"
+ fi
+ if [[ "${ACTIVETRANSFERS}" -ge "${TRANSFERS}" ]]; then
+ if [[ ! -f "${CAPACITY_FLAG}" ]]; then
+ log "Capacity reached: ${ACTIVETRANSFERS}/${TRANSFERS}. Waiting for slots."
+ : > "${CAPACITY_FLAG}"
fi
+ $(which sleep) 2
+ break
fi
- fi
- fi
-}
-function notification() {
- source /system/uploader/uploader.env
- #### CHECK NOTIFICATION TYPE ####
- if [[ "${NOTIFYTYPE}" == "" ]]; then
- NOTIFYTYPE="info"
- fi
- #### CHECK NOTIFICATON SERVERNAME ####
- if [[ "${NOTIFICATION_SERVERNAME}" == "null" ]]; then
- NOTIFICATION_NAME="Docker"
- else
- NOTIFICATION_NAME="${NOTIFICATION_SERVERNAME}"
- fi
- #### SEND NOTIFICATION ####
- if [[ "${NOTIFICATION_URL}" != "null" ]]; then
- log "${MSG}" && apprise --notification-type="${NOTIFYTYPE}" --title="Uploader - ${NOTIFICATION_NAME}" --body="${MSGAPP}" "${NOTIFICATION_URL}/?format=markdown"
- else
- log "${MSG}"
- fi
+ # Capacity available again; clear flag so future capacity states can log once
+ [ -f "${CAPACITY_FLAG}" ] && $(which rm) -f "${CAPACITY_FLAG}" 2>/dev/null
+
+ FILE=$(sqlite3read "SELECT filebase FROM upload_queue WHERE metadata = 0 ${SEARCHSTRING} LIMIT 1;" 2>/dev/null)
+ if [[ -z "${FILE}" ]]; then
+ log "Queue empty while filling slots. No files to start."
+ break
+ fi
+ DIR=$(sqlite3read "SELECT filedir FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+ DRIVE=$(sqlite3read "SELECT drive FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+ SIZEBYTES=$(sqlite3read "SELECT filesize FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+
+ #### TO CHECK IS IT A FILE OR NOT ####
+ if [[ -f "${DLFOLDER}/${DIR}/${FILE}" ]]; then
+ #### REPULL SOURCE FILE FOR LIVE EDITS ####
+ source /system/uploader/uploader.env
+ #### RUN TRANSFERS CHECK ####
+ transfercheck
+ #### UPLOAD FUNCTIONS STARTUP ####
+ if [[ "${TRANSFERS}" -eq "1" ]]; then
+ #### SINGLE UPLOAD ####
+ log "Starting upload (single mode): ${DRIVE}/${DIR}/${FILE}"
+ rcloneupload
+ else
+ #### DEMONISED UPLOAD ####
+ log "Starting upload in background: ${DRIVE}/${DIR}/${FILE} (active ${ACTIVETRANSFERS}/${TRANSFERS})"
+ rcloneupload &
+ fi
+ else
+ #### WHEN NOT THEN DELETE ENTRY ####
+ log "File missing, removing from queue: ${DRIVE}/${DIR}/${FILE}"
+ FULLPATH="${DLFOLDER}/${DIR}/${FILE}"
+ DBINFO=$(sqlite3read "SELECT drive||'|'||filedir FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}' LIMIT 1;" 2>/dev/null)
+ log "Debug: missing-check fullpath='${FULLPATH}' (DLFOLDER='${DLFOLDER}', DIR='${DIR}', FILE='${FILE}', DB='${DBINFO}')"
+ sqlite3write "DELETE FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" &>/dev/null
+ # Skip to next file in queue
+ continue
+ fi
+
+ #### Recompute remaining queue count ####
+ CHECKFILES=$(sqlite3read "SELECT COUNT(*) FROM upload_queue WHERE metadata = 0;")
+ if [[ "${CHECKFILES}" -lt "1" ]]; then
+ log "Finished filling slots; queue now empty."
+ break
+ fi
+ done
+
+ #### CLEANUP COMPLETED HISTORY ####
+ cleanuplog
}
function checkerror() {
source /system/uploader/uploader.env
- CHECKERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long")
+ CHECKERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -v "preAllocate" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long")
DATEDIFF="$(( ${ENDZ} - ${STARTZ} ))"
HOUR="$(( ${DATEDIFF} / 3600 ))"
MINUTE="$(( (${DATEDIFF} % 3600) / 60 ))"
@@ -126,7 +168,7 @@ function checkerror() {
#### CHECK ERROR ON UPLOAD ####
if [[ "${CHECKERROR}" != "" ]]; then
STATUS="0"
- ERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long" | $(which head) -n 1 | $(which tr) -d '"')
+ ERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -v "preAllocate" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long" | $(which head) -n 1 | $(which tr) -d '"')
NOTIFYTYPE="failure"
if [[ "${NOTIFICATION_LEVEL}" == "ALL" ]] || [[ ${NOTIFICATION_LEVEL} == "ERROR" ]]; then
MSG="-> ❌ Upload failed ${FILE} with Error ${ERROR} <-"
@@ -328,7 +370,7 @@ function rcloneupload() {
$(which rclone) moveto "${DLFOLDER}/${DIR}/${FILE}" "${REMOTENAME}:/${DIR}/${FILE}" \
--config="${CONFIG}" \
--stats=1s --checkers=4 \
- --dropbox-chunk-size=128M --use-mmap \
+ --dropbox-chunk-size=128M \
--log-level="${LOG_LEVEL}" \
--user-agent="${USERAGENT}" ${BWLIMIT} \
--log-file="${LOGFILE}/${FILE}.txt" \
@@ -343,7 +385,30 @@ function rcloneupload() {
checkerror
#### ECHO END-PARTS FOR UI READING ####
$(which find) "${DLFOLDER}/${SETDIR}" -mindepth 1 -type d -empty -delete &>/dev/null
- sqlite3write "INSERT INTO completed_uploads (drive,filedir,filebase,filesize,gdsa,starttime,endtime,status,error) VALUES ('${DRIVE//\'/\'\'}','${DIR//\'/\'\'}','${FILE//\'/\'\'}','${SIZE}','${REMOTENAME//\'/\'\'}','${STARTZ}','${ENDZ}','${STATUS//\'/\'\'}','${ERROR//\'/\'\'}'); DELETE FROM uploads WHERE filebase = '${FILE//\'/\'\'}';" &>/dev/null
+
+ # Make sure SIZEBYTES contains the actual size in bytes for storage
+ if [[ -z "${SIZEBYTES}" || "${SIZEBYTES}" == "0" ]]; then
+ # If SIZEBYTES is empty or zero, try to derive it from SIZE
+ if [[ "${SIZE}" =~ ^([0-9.]+)\ ?([KMGT]i?B) ]]; then
+ NUM=${BASH_REMATCH[1]}
+ UNIT=${BASH_REMATCH[2]}
+ case "${UNIT}" in
+ B) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1}') ;;
+ KB|KiB) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1 * 1024}') ;;
+ MB|MiB) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1 * 1024 * 1024}') ;;
+ GB|GiB) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1 * 1024 * 1024 * 1024}') ;;
+ TB|TiB) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1 * 1024 * 1024 * 1024 * 1024}') ;;
+ *) SIZEBYTES=0 ;;
+ esac
+ else
+ # Default to zero if we can't parse
+ SIZEBYTES=0
+ fi
+ fi
+
+ # Insert into completed_uploads with the filesize_bytes field
+ sqlite3write "INSERT INTO completed_uploads (drive,filedir,filebase,filesize,filesize_bytes,gdsa,starttime,endtime,status,error) VALUES ('${DRIVE//\'/\'\'}','${DIR//\'/\'\'}','${FILE//\'/\'\'}','${SIZE}','${SIZEBYTES}','${REMOTENAME//\'/\'\'}','${STARTZ}','${ENDZ}','${STATUS//\'/\'\'}','${ERROR//\'/\'\'}'); DELETE FROM uploads WHERE filebase = '${FILE//\'/\'\'}';" &>/dev/null
+
#### END OF MOVE ####
$(which rm) -rf "${LOGFILE}/${FILE}.txt" &>/dev/null
#### REMOVE CUSTOM RCLONE.CONF ####
@@ -354,6 +419,19 @@ function rcloneupload() {
function listfiles() {
source /system/uploader/uploader.env
+
+ # Validate MIN_AGE_UPLOAD
+ if [[ -z "${MIN_AGE_UPLOAD}" || ! "${MIN_AGE_UPLOAD}" =~ ^[0-9]+$ ]]; then
+ MIN_AGE_UPLOAD=1
+ log "Warning: MIN_AGE_UPLOAD was invalid, reset to 1"
+ fi
+
+ # Validate FOLDER_DEPTH
+ if [[ -z "${FOLDER_DEPTH}" || ! "${FOLDER_DEPTH}" =~ ^[0-9]+$ || "${FOLDER_DEPTH}" -lt 1 ]]; then
+ FOLDER_DEPTH=1
+ log "Warning: FOLDER_DEPTH was invalid, reset to 1"
+ fi
+
#### CREATE TEMP_FILE ####
sqlite3read "SELECT filebase FROM upload_queue UNION ALL SELECT filebase FROM uploads;" > "${TEMPFILES}"
#### FIND NEW FILES ####
@@ -363,7 +441,12 @@ function listfiles() {
for NAME in ${FILEBASE[@]}; do
LISTFILE=$($(which basename) "${NAME}")
LISTDIR=$($(which dirname) "${NAME}")
- LISTDRIVE=$($(which echo) "${LISTDIR}" | $(which cut) -d/ -f-"${FOLDER_DEPTH}" | $(which xargs) -I {} $(which basename) {})
+ # Ensure FOLDER_DEPTH is valid
+ if [[ -z "${FOLDER_DEPTH}" || "${FOLDER_DEPTH}" -lt 1 ]]; then
+ FOLDER_DEPTH=1
+ log "Warning: FOLDER_DEPTH was invalid, reset to 1"
+ fi
+ LISTDRIVE=$($(which echo) "${LISTDIR}" | $(which cut) -d/ -f1-"${FOLDER_DEPTH}" | $(which xargs) -I {} $(which basename) {})
LISTSIZE=$($(which stat) -c %s "${DLFOLDER}/${NAME}" 2>/dev/null)
LISTTYPE="${NAME##*.}"
if [[ "${LISTTYPE}" == "mkv" ]] || [[ "${LISTTYPE}" == "mp4" ]] || [[ "${LISTTYPE}" == "avi" ]] || [[ "${LISTTYPE}" == "mov" ]] || [[ "${LISTTYPE}" == "mpeg" ]] || [[ "${LISTTYPE}" == "mpegts" ]] || [[ "${LISTTYPE}" == "ts" ]]; then
@@ -374,7 +457,6 @@ function listfiles() {
if [[ "${STRIPARR_URL}" == "" ]]; then
STRIPARR_URL="null"
fi
-
if [[ "${STRIPARR_URL}" == "null" ]]; then
sqlite3write "INSERT OR IGNORE INTO upload_queue (drive,filedir,filebase,filesize,metadata) SELECT '${LISTDRIVE//\'/\'\'}','${LISTDIR//\'/\'\'}','${LISTFILE//\'/\'\'}','${LISTSIZE}','0' WHERE NOT EXISTS (SELECT 1 FROM uploads WHERE filebase = '${LISTFILE//\'/\'\'}');" &>/dev/null
else
@@ -414,7 +496,7 @@ function checkmeta() {
function checkspace() {
source /system/uploader/uploader.env
#### CHECK DRIVEUSEDSPACE ####
- if [[ "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
+ if [[ "${DRIVEUSEDSPACE}" != "null" && "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
while true; do
LCT=$($(which df) --output=pcent "${DLFOLDER}" | tr -dc '0-9')
if [[ "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
diff --git a/apps/docker-uploader/root/app/uploader/function-gdsa.sh b/apps/docker-uploader/root/app/uploader/function-gdsa.sh
index 35bd7762ea..2c85614940 100755
--- a/apps/docker-uploader/root/app/uploader/function-gdsa.sh
+++ b/apps/docker-uploader/root/app/uploader/function-gdsa.sh
@@ -106,7 +106,7 @@ function notification() {
function checkerror() {
source /system/uploader/uploader.env
- CHECKERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long")
+ CHECKERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -v "preAllocate" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long")
DATEDIFF="$(( ${ENDZ} - ${STARTZ} ))"
HOUR="$(( ${DATEDIFF} / 3600 ))"
MINUTE="$(( (${DATEDIFF} % 3600) / 60 ))"
@@ -126,7 +126,7 @@ function checkerror() {
#### CHECK ERROR ON UPLOAD ####
if [[ "${CHECKERROR}" != "" ]]; then
STATUS="0"
- ERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long" | $(which head) -n 1 | $(which tr) -d '"')
+ ERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -v "preAllocate" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long" | $(which head) -n 1 | $(which tr) -d '"')
NOTIFYTYPE="failure"
if [[ "${NOTIFICATION_LEVEL}" == "ALL" ]] || [[ ${NOTIFICATION_LEVEL} == "ERROR" ]]; then
MSG="-> ❌ Upload failed ${FILE} with Error ${ERROR} <-"
@@ -237,7 +237,7 @@ function replace-used() {
USEDUPLOAD="0"
fi
#### UPDATE USED FILE ####
- sqlite3write "UPDATE upload_keys SET used = used + ${SIZEBYTES}, time = datetime('now', 'localtime') WHERE active = 1;" &>/dev/null
+ sqlite3write "UPDATE upload_keys SET used = used + \"${SIZEBYTES}\", time = datetime('now', 'localtime') WHERE active = 1;" &>/dev/null
}
function reset-used() {
@@ -350,7 +350,7 @@ function rcloneupload() {
$(which rclone) moveto "${DLFOLDER}/${DIR}/${FILE}" "${REMOTENAME}:/${DIR}/${FILE}" \
--config="${CONFIG}" \
--stats=1s --checkers=4 \
- --drive-chunk-size=32M --use-mmap \
+ --drive-chunk-size=32M \
--log-level="${LOG_LEVEL}" \
--user-agent="${USERAGENT}" ${BWLIMIT} \
--log-file="${LOGFILE}/${FILE}.txt" \
@@ -365,7 +365,31 @@ function rcloneupload() {
checkerror
#### ECHO END-PARTS FOR UI READING ####
$(which find) "${DLFOLDER}/${SETDIR}" -mindepth 1 -type d -empty -delete &>/dev/null
- sqlite3write "INSERT INTO completed_uploads (drive,filedir,filebase,filesize,gdsa,starttime,endtime,status,error) VALUES ('${DRIVE//\'/\'\'}','${DIR//\'/\'\'}','${FILE//\'/\'\'}','${SIZE}','${REMOTENAME//\'/\'\'}','${STARTZ}','${ENDZ}','${STATUS//\'/\'\'}','${ERROR//\'/\'\'}'); DELETE FROM uploads WHERE filebase = '${FILE//\'/\'\'}';" &>/dev/null
+
+ # Store the original size in bytes for the database
+ # Make sure SIZEBYTES contains the actual size in bytes for storage
+ if [[ -z "${SIZEBYTES}" || "${SIZEBYTES}" == "0" ]]; then
+ # If SIZEBYTES is empty or zero, try to derive it from SIZE
+ if [[ "${SIZE}" =~ ^([0-9.]+)\ ?([KMGT]i?B) ]]; then
+ NUM=${BASH_REMATCH[1]}
+ UNIT=${BASH_REMATCH[2]}
+ case "${UNIT}" in
+ B) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1}') ;;
+ KB|KiB) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1 * 1024}') ;;
+ MB|MiB) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1 * 1024 * 1024}') ;;
+ GB|GiB) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1 * 1024 * 1024 * 1024}') ;;
+ TB|TiB) SIZEBYTES=$(echo "${NUM}" | awk '{printf "%d", $1 * 1024 * 1024 * 1024 * 1024}') ;;
+ *) SIZEBYTES=0 ;;
+ esac
+ else
+ # Default to zero if we can't parse
+ SIZEBYTES=0
+ fi
+ fi
+
+ # Insert into completed_uploads with the filesize_bytes field
+ sqlite3write "INSERT INTO completed_uploads (drive,filedir,filebase,filesize,filesize_bytes,gdsa,starttime,endtime,status,error) VALUES ('${DRIVE//\'/\'\'}','${DIR//\'/\'\'}','${FILE//\'/\'\'}','${SIZE}','${SIZEBYTES}','${KEYNOTI//\'/\'\'}${CRYPTED//\'/\'\'}','${STARTZ}','${ENDZ}','${STATUS//\'/\'\'}','${ERROR//\'/\'\'}'); DELETE FROM uploads WHERE filebase = '${FILE//\'/\'\'}';" &>/dev/null
+
#### END OF MOVE ####
$(which rm) -rf "${LOGFILE}/${FILE}.txt" &>/dev/null
#### REMOVE CUSTOM RCLONE.CONF ####
@@ -376,6 +400,19 @@ function rcloneupload() {
function listfiles() {
source /system/uploader/uploader.env
+
+ # Validate MIN_AGE_UPLOAD
+ if [[ -z "${MIN_AGE_UPLOAD}" || ! "${MIN_AGE_UPLOAD}" =~ ^[0-9]+$ ]]; then
+ MIN_AGE_UPLOAD=1
+ log "Warning: MIN_AGE_UPLOAD was invalid, reset to 1"
+ fi
+
+ # Validate FOLDER_DEPTH
+ if [[ -z "${FOLDER_DEPTH}" || ! "${FOLDER_DEPTH}" =~ ^[0-9]+$ || "${FOLDER_DEPTH}" -lt 1 ]]; then
+ FOLDER_DEPTH=1
+ log "Warning: FOLDER_DEPTH was invalid, reset to 1"
+ fi
+
#### CREATE TEMP_FILE ####
sqlite3read "SELECT filebase FROM upload_queue UNION ALL SELECT filebase FROM uploads;" > "${TEMPFILES}"
#### FIND NEW FILES ####
@@ -385,7 +422,12 @@ function listfiles() {
for NAME in ${FILEBASE[@]}; do
LISTFILE=$($(which basename) "${NAME}")
LISTDIR=$($(which dirname) "${NAME}")
- LISTDRIVE=$($(which echo) "${LISTDIR}" | $(which cut) -d/ -f-"${FOLDER_DEPTH}" | $(which xargs) -I {} $(which basename) {})
+ # Ensure FOLDER_DEPTH is valid
+ if [[ -z "${FOLDER_DEPTH}" || "${FOLDER_DEPTH}" -lt 1 ]]; then
+ FOLDER_DEPTH=1
+ log "Warning: FOLDER_DEPTH was invalid, reset to 1"
+ fi
+ LISTDRIVE=$($(which echo) "${LISTDIR}" | $(which cut) -d/ -f1-"${FOLDER_DEPTH}" | $(which xargs) -I {} $(which basename) {})
LISTSIZE=$($(which stat) -c %s "${DLFOLDER}/${NAME}" 2>/dev/null)
LISTTYPE="${NAME##*.}"
if [[ "${LISTTYPE}" == "mkv" ]] || [[ "${LISTTYPE}" == "mp4" ]] || [[ "${LISTTYPE}" == "avi" ]] || [[ "${LISTTYPE}" == "mov" ]] || [[ "${LISTTYPE}" == "mpeg" ]] || [[ "${LISTTYPE}" == "mpegts" ]] || [[ "${LISTTYPE}" == "ts" ]]; then
@@ -396,7 +438,6 @@ function listfiles() {
if [[ "${STRIPARR_URL}" == "" ]]; then
STRIPARR_URL="null"
fi
-
if [[ "${STRIPARR_URL}" == "null" ]]; then
sqlite3write "INSERT OR IGNORE INTO upload_queue (drive,filedir,filebase,filesize,metadata) SELECT '${LISTDRIVE//\'/\'\'}','${LISTDIR//\'/\'\'}','${LISTFILE//\'/\'\'}','${LISTSIZE}','0' WHERE NOT EXISTS (SELECT 1 FROM uploads WHERE filebase = '${LISTFILE//\'/\'\'}');" &>/dev/null
else
@@ -436,7 +477,7 @@ function checkmeta() {
function checkspace() {
source /system/uploader/uploader.env
#### CHECK DRIVEUSEDSPACE ####
- if [[ "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
+ if [[ "${DRIVEUSEDSPACE}" != "null" && "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
while true; do
LCT=$($(which df) --output=pcent "${DLFOLDER}" | tr -dc '0-9')
if [[ "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
@@ -521,10 +562,37 @@ function startuploader() {
else
SEARCHSTRING="ORDER BY time"
fi
- FILE=$(sqlite3read "SELECT filebase FROM upload_queue WHERE metadata = 0 ${SEARCHSTRING} LIMIT 1;" 2>/dev/null)
- DIR=$(sqlite3read "SELECT filedir FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
- DRIVE=$(sqlite3read "SELECT drive FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
- SIZEBYTES=$(sqlite3read "SELECT filesize FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+
+ #### Fill available transfer slots in this cycle ####
+ LOGGED_CAPACITY=false
+ CAPACITY_FLAG="/tmp/uploader_capacity_logged"
+ while true; do
+ ACTIVETRANSFERS=$(sqlite3read "SELECT COUNT(*) FROM uploads;" 2>/dev/null)
+ source /system/uploader/uploader.env
+ if [[ "${TRANSFERS}" != +([0-9.]) ]] || [ "${TRANSFERS}" -gt "99" ] || [ "${TRANSFERS}" -eq "0" ]; then
+ TRANSFERS="1"
+ fi
+ if [[ "${ACTIVETRANSFERS}" -ge "${TRANSFERS}" ]]; then
+ if [[ ! -f "${CAPACITY_FLAG}" ]]; then
+ log "Capacity reached: ${ACTIVETRANSFERS}/${TRANSFERS}. Waiting for slots."
+ : > "${CAPACITY_FLAG}"
+ fi
+ $(which sleep) 2
+ break
+ fi
+
+ # Capacity available again; clear flag so future capacity states can log once
+ [ -f "${CAPACITY_FLAG}" ] && $(which rm) -f "${CAPACITY_FLAG}" 2>/dev/null
+
+ FILE=$(sqlite3read "SELECT filebase FROM upload_queue WHERE metadata = 0 ${SEARCHSTRING} LIMIT 1;" 2>/dev/null)
+ if [[ -z "${FILE}" ]]; then
+ log "Queue empty while filling slots. No files to start."
+ break
+ fi
+ DIR=$(sqlite3read "SELECT filedir FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+ DRIVE=$(sqlite3read "SELECT drive FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+ SIZEBYTES=$(sqlite3read "SELECT filesize FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+
#### TO CHECK IS IT A FILE OR NOT ####
if [[ -f "${DLFOLDER}/${DIR}/${FILE}" ]]; then
#### REPULL SOURCE FILE FOR LIVE EDITS ####
@@ -538,16 +606,32 @@ function startuploader() {
#### UPLOAD FUNCTIONS STARTUP ####
if [[ "${TRANSFERS}" -eq "1" ]]; then
#### SINGLE UPLOAD ####
+ log "Starting upload (single mode): ${DRIVE}/${DIR}/${FILE}"
rcloneupload
else
#### DEMONISED UPLOAD ####
+ log "Starting upload in background: ${DRIVE}/${DIR}/${FILE} (active ${ACTIVETRANSFERS}/${TRANSFERS})"
rcloneupload &
fi
else
#### WHEN NOT THEN DELETE ENTRY ####
+ log "File missing, removing from queue: ${DRIVE}/${DIR}/${FILE}"
+ FULLPATH="${DLFOLDER}/${DIR}/${FILE}"
+ DBINFO=$(sqlite3read "SELECT drive||'|'||filedir FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}' LIMIT 1;" 2>/dev/null)
+ log "Debug: missing-check fullpath='${FULLPATH}' (DLFOLDER='${DLFOLDER}', DIR='${DIR}', FILE='${FILE}', DB='${DBINFO}')"
sqlite3write "DELETE FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" &>/dev/null
- $(which sleep) 2
+ # Skip to next file in queue
+ continue
+ fi
+
+ #### Recompute remaining queue count ####
+ CHECKFILES=$(sqlite3read "SELECT COUNT(*) FROM upload_queue WHERE metadata = 0;")
+ if [[ "${CHECKFILES}" -lt "1" ]]; then
+ log "Finished filling slots; queue now empty."
+ break
fi
+ done
+
#### CLEANUP COMPLETED HISTORY ####
cleanuplog
else
diff --git a/apps/docker-uploader/root/app/uploader/function.sh b/apps/docker-uploader/root/app/uploader/function.sh
index 6b17b77e53..14af9bd794 100755
--- a/apps/docker-uploader/root/app/uploader/function.sh
+++ b/apps/docker-uploader/root/app/uploader/function.sh
@@ -48,6 +48,42 @@ function log() {
$(which echo) -e "${GRAY}[$($(which date) +'%Y/%m/%d %H:%M:%S')]${BLUE} [Uploader]${NC} ${1}"
}
+function update_env_var() {
+ # $1 = variable name (e.g., BANDWIDTH_LIMIT)
+ # $2 = new value (e.g., "30M")
+ local VAR_NAME="$1"
+ local NEW_VALUE="$2"
+ local ENV_FILE="/system/uploader/uploader.env"
+
+ # Create a temporary file
+ local TEMP_FILE=$(mktemp)
+
+ # Read line by line, update the specific variable
+ while IFS= read -r line; do
+ if [[ "$line" =~ ^$VAR_NAME= ]]; then
+ # Special handling for string vs numeric/boolean values
+ if [[ "$NEW_VALUE" == "true" || "$NEW_VALUE" == "false" || "$NEW_VALUE" == "null" || "$NEW_VALUE" =~ ^[0-9]+$ ]]; then
+ # Boolean, null, or numeric values don't need quotes
+ echo "${VAR_NAME}=${NEW_VALUE}" >> "$TEMP_FILE"
+ else
+ # String values should be quoted
+ echo "${VAR_NAME}=\"${NEW_VALUE}\"" >> "$TEMP_FILE"
+ fi
+ else
+ echo "$line" >> "$TEMP_FILE"
+ fi
+ done < "$ENV_FILE"
+
+ # Replace original file
+ mv "$TEMP_FILE" "$ENV_FILE"
+
+ # Set permissions
+ chmod 755 "$ENV_FILE"
+ chown abc:abc "$ENV_FILE"
+
+ log "Updated ${VAR_NAME} to ${NEW_VALUE} in environment file"
+}
+
function refreshVFS() {
source /system/uploader/uploader.env
#### SEND VFS REFRESH TO MOUNT ####
@@ -106,7 +142,7 @@ function notification() {
function checkerror() {
source /system/uploader/uploader.env
- CHECKERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long")
+ CHECKERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -v "preAllocate" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long")
DATEDIFF="$(( ${ENDZ} - ${STARTZ} ))"
HOUR="$(( ${DATEDIFF} / 3600 ))"
MINUTE="$(( (${DATEDIFF} % 3600) / 60 ))"
@@ -126,7 +162,7 @@ function checkerror() {
#### CHECK ERROR ON UPLOAD ####
if [[ "${CHECKERROR}" != "" ]]; then
STATUS="0"
- ERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long" | $(which head) -n 1 | $(which tr) -d '"')
+ ERROR=$($(which tail) -n 25 "${LOGFILE}/${FILE}.txt" | $(which grep) -v "preAllocate" | $(which grep) -E -wi "Failed|ERROR|Source doesn't exist or is a directory and destination is a file|The filename or extension is too long|file name too long|Filename too long" | $(which head) -n 1 | $(which tr) -d '"')
NOTIFYTYPE="failure"
if [[ "${NOTIFICATION_LEVEL}" == "ALL" ]] || [[ ${NOTIFICATION_LEVEL} == "ERROR" ]]; then
MSG="-> ❌ Upload failed ${FILE} with Error ${ERROR} <-"
@@ -191,6 +227,12 @@ function cleanuplog() {
function rcloneupload() {
source /system/uploader/uploader.env
+ # Validate BANDWIDTH_LIMIT
+ if [[ "${BANDWIDTH_LIMIT}" != "null" && ! "${BANDWIDTH_LIMIT}" =~ ^[0-9]+[KMG]?$ ]]; then
+ log "Warning: BANDWIDTH_LIMIT format invalid, setting to null"
+ BANDWIDTH_LIMIT="null"
+ fi
+
FILETYPE="${FILE##*.}"
SETDIR=$($(which echo) "${DIR}" | $(which cut) -d/ -f-"${FOLDER_DEPTH}")
SIZE=$($(which echo) "${SIZEBYTES}" | $(which numfmt) --to=iec-i --suffix=B)
@@ -274,7 +316,7 @@ function rcloneupload() {
$(which rclone) moveto "${DLFOLDER}/${DIR}/${FILE}" "${REMOTENAME}:/${DIR}/${FILE}" \
--config="${CONFIG}" \
--stats=1s --checkers=4 \
- --drive-chunk-size=32M --use-mmap \
+ --drive-chunk-size=32M \
--log-level="${LOG_LEVEL}" \
--user-agent="${USERAGENT}" ${BWLIMIT} \
--log-file="${LOGFILE}/${FILE}.txt" \
@@ -300,6 +342,19 @@ function rcloneupload() {
function listfiles() {
source /system/uploader/uploader.env
+
+ # Validate MIN_AGE_UPLOAD
+ if [[ -z "${MIN_AGE_UPLOAD}" || ! "${MIN_AGE_UPLOAD}" =~ ^[0-9]+$ ]]; then
+ MIN_AGE_UPLOAD=1
+ log "Warning: MIN_AGE_UPLOAD was invalid, reset to 1"
+ fi
+
+ # Validate FOLDER_DEPTH
+ if [[ -z "${FOLDER_DEPTH}" || ! "${FOLDER_DEPTH}" =~ ^[0-9]+$ || "${FOLDER_DEPTH}" -lt 1 ]]; then
+ FOLDER_DEPTH=1
+ log "Warning: FOLDER_DEPTH was invalid, reset to 1"
+ fi
+
#### CREATE TEMP_FILE ####
sqlite3read "SELECT filebase FROM upload_queue UNION ALL SELECT filebase FROM uploads;" > "${TEMPFILES}"
#### FIND NEW FILES ####
@@ -309,7 +364,12 @@ function listfiles() {
for NAME in ${FILEBASE[@]}; do
LISTFILE=$($(which basename) "${NAME}")
LISTDIR=$($(which dirname) "${NAME}")
- LISTDRIVE=$($(which echo) "${LISTDIR}" | $(which cut) -d/ -f-"${FOLDER_DEPTH}" | $(which xargs) -I {} $(which basename) {})
+ # Ensure FOLDER_DEPTH is valid
+ if [[ -z "${FOLDER_DEPTH}" || "${FOLDER_DEPTH}" -lt 1 ]]; then
+ FOLDER_DEPTH=1
+ log "Warning: FOLDER_DEPTH was invalid, reset to 1"
+ fi
+ LISTDRIVE=$($(which echo) "${LISTDIR}" | $(which cut) -d/ -f1-"${FOLDER_DEPTH}" | $(which xargs) -I {} $(which basename) {})
LISTSIZE=$($(which stat) -c %s "${DLFOLDER}/${NAME}" 2>/dev/null)
LISTTYPE="${NAME##*.}"
if [[ "${LISTTYPE}" == "mkv" ]] || [[ "${LISTTYPE}" == "mp4" ]] || [[ "${LISTTYPE}" == "avi" ]] || [[ "${LISTTYPE}" == "mov" ]] || [[ "${LISTTYPE}" == "mpeg" ]] || [[ "${LISTTYPE}" == "mpegts" ]] || [[ "${LISTTYPE}" == "ts" ]]; then
@@ -320,7 +380,6 @@ function listfiles() {
if [[ "${STRIPARR_URL}" == "" ]]; then
STRIPARR_URL="null"
fi
-
if [[ "${STRIPARR_URL}" == "null" ]]; then
sqlite3write "INSERT OR IGNORE INTO upload_queue (drive,filedir,filebase,filesize,metadata) SELECT '${LISTDRIVE//\'/\'\'}','${LISTDIR//\'/\'\'}','${LISTFILE//\'/\'\'}','${LISTSIZE}','0' WHERE NOT EXISTS (SELECT 1 FROM uploads WHERE filebase = '${LISTFILE//\'/\'\'}');" &>/dev/null
else
@@ -360,7 +419,7 @@ function checkmeta() {
function checkspace() {
source /system/uploader/uploader.env
#### CHECK DRIVEUSEDSPACE ####
- if [[ "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
+ if [[ "${DRIVEUSEDSPACE}" != "null" && "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
while true; do
LCT=$($(which df) --output=pcent "${DLFOLDER}" | tr -dc '0-9')
if [[ "${DRIVEUSEDSPACE}" =~ ^[0-9][0-9]+([.][0-9]+)?$ ]]; then
@@ -445,10 +504,37 @@ function startuploader() {
else
SEARCHSTRING="ORDER BY time"
fi
- FILE=$(sqlite3read "SELECT filebase FROM upload_queue WHERE metadata = 0 ${SEARCHSTRING} LIMIT 1;" 2>/dev/null)
- DIR=$(sqlite3read "SELECT filedir FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
- DRIVE=$(sqlite3read "SELECT drive FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
- SIZEBYTES=$(sqlite3read "SELECT filesize FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+
+ #### Fill available transfer slots in this cycle ####
+ LOGGED_CAPACITY=false
+ CAPACITY_FLAG="/tmp/uploader_capacity_logged"
+ while true; do
+ ACTIVETRANSFERS=$(sqlite3read "SELECT COUNT(*) FROM uploads;" 2>/dev/null)
+ source /system/uploader/uploader.env
+ if [[ "${TRANSFERS}" != +([0-9.]) ]] || [ "${TRANSFERS}" -gt "99" ] || [ "${TRANSFERS}" -eq "0" ]; then
+ TRANSFERS="1"
+ fi
+ if [[ "${ACTIVETRANSFERS}" -ge "${TRANSFERS}" ]]; then
+ if [[ ! -f "${CAPACITY_FLAG}" ]]; then
+ log "Capacity reached: ${ACTIVETRANSFERS}/${TRANSFERS}. Waiting for slots."
+ : > "${CAPACITY_FLAG}"
+ fi
+ $(which sleep) 2
+ break
+ fi
+
+ # Capacity available again; clear flag so future capacity states can log once
+ [ -f "${CAPACITY_FLAG}" ] && $(which rm) -f "${CAPACITY_FLAG}" 2>/dev/null
+
+ FILE=$(sqlite3read "SELECT filebase FROM upload_queue WHERE metadata = 0 ${SEARCHSTRING} LIMIT 1;" 2>/dev/null)
+ if [[ -z "${FILE}" ]]; then
+ log "Queue empty while filling slots. No files to start."
+ break
+ fi
+ DIR=$(sqlite3read "SELECT filedir FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+ DRIVE=$(sqlite3read "SELECT drive FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+ SIZEBYTES=$(sqlite3read "SELECT filesize FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" 2>/dev/null)
+
#### TO CHECK IS IT A FILE OR NOT ####
if [[ -f "${DLFOLDER}/${DIR}/${FILE}" ]]; then
#### REPULL SOURCE FILE FOR LIVE EDITS ####
@@ -458,16 +544,33 @@ function startuploader() {
#### UPLOAD FUNCTIONS STARTUP ####
if [[ "${TRANSFERS}" -eq "1" ]]; then
#### SINGLE UPLOAD ####
+ log "Starting upload (single mode): ${DRIVE}/${DIR}/${FILE}"
rcloneupload
else
#### DEMONISED UPLOAD ####
+ log "Starting upload in background: ${DRIVE}/${DIR}/${FILE} (active ${ACTIVETRANSFERS}/${TRANSFERS})"
rcloneupload &
fi
else
#### WHEN NOT THEN DELETE ENTRY ####
+ log "File missing, removing from queue: ${DRIVE}/${DIR}/${FILE}"
+ # Debug details to diagnose path composition issues
+ FULLPATH="${DLFOLDER}/${DIR}/${FILE}"
+ DBINFO=$(sqlite3read "SELECT drive||'|'||filedir FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}' LIMIT 1;" 2>/dev/null)
+ log "Debug: missing-check fullpath='${FULLPATH}' (DLFOLDER='${DLFOLDER}', DIR='${DIR}', FILE='${FILE}', DB='${DBINFO}')"
sqlite3write "DELETE FROM upload_queue WHERE filebase = '${FILE//\'/\'\'}';" &>/dev/null
- $(which sleep) 2
+ # Skip to next file in queue
+ continue
fi
+
+ #### Recompute remaining queue count ####
+ CHECKFILES=$(sqlite3read "SELECT COUNT(*) FROM upload_queue WHERE metadata = 0;")
+ if [[ "${CHECKFILES}" -lt "1" ]]; then
+ log "Finished filling slots; queue now empty."
+ break
+ fi
+ done
+
#### CLEANUP COMPLETED HISTORY ####
cleanuplog
else
@@ -477,4 +580,4 @@ function startuploader() {
done
}
-#### END OF FILE ####
+#### END OF FILE ####
\ No newline at end of file
diff --git a/apps/docker-uploader/root/etc/s6-overlay/s6-rc.d/init-install/run b/apps/docker-uploader/root/etc/s6-overlay/s6-rc.d/init-install/run
index 24b7aebec2..7a0b4a432a 100755
--- a/apps/docker-uploader/root/etc/s6-overlay/s6-rc.d/init-install/run
+++ b/apps/docker-uploader/root/etc/s6-overlay/s6-rc.d/init-install/run
@@ -37,7 +37,80 @@ USEDDIR=/system/uploader/.keys
JSONOLD=/system/uploader/json
ARRAY=$($(which ls) -A "${JSONDIR}" | $(which wc) -l)
- $(which cat) >> /etc/apk/repositories << EOF; $(echo)
+# Validate the environment file to ensure we don't lose settings
+function validate_env_file() {
+ local ENV_FILE="/system/uploader/uploader.env"
+
+ if [ ! -f "$ENV_FILE" ]; then
+ log "Error: $ENV_FILE not found, will create default file"
+ return 1
+ fi
+
+ # Check for essential variables
+ local MISSING=0
+ local ESSENTIAL_VARS=("PUID" "PGID" "TIMEZONE" "BANDWIDTH_LIMIT" "TRANSFERS" "FOLDER_DEPTH" "MIN_AGE_UPLOAD")
+
+ for var in "${ESSENTIAL_VARS[@]}"; do
+ if ! grep -q "^${var}=" "$ENV_FILE"; then
+ log "Missing essential variable: $var"
+ MISSING=1
+ fi
+ done
+
+ # If missing any essential variables, recreate the file
+ if [ "$MISSING" -eq 1 ]; then
+ log "Will recreate env file with missing variables"
+ return 1
+ fi
+
+ # Check variable format for common issues
+ if grep -q "^BANDWIDTH_LIMIT=[^\"']" "$ENV_FILE"; then
+ log "BANDWIDTH_LIMIT is not properly quoted, fixing"
+ sed -i 's|^BANDWIDTH_LIMIT=\(.*\)|BANDWIDTH_LIMIT=\"\1\"|g' "$ENV_FILE"
+ fi
+
+ return 0
+}
+
+# Update a single environment variable safely
+function update_env_var() {
+ # $1 = variable name (e.g., BANDWIDTH_LIMIT)
+ # $2 = new value (e.g., "30M")
+ local VAR_NAME="$1"
+ local NEW_VALUE="$2"
+ local ENV_FILE="/system/uploader/uploader.env"
+
+ # Create a temporary file
+ local TEMP_FILE=$(mktemp)
+
+ # Read line by line, update the specific variable
+ while IFS= read -r line; do
+ if [[ "$line" =~ ^$VAR_NAME= ]]; then
+ # Special handling for string vs numeric/boolean values
+ if [[ "$NEW_VALUE" == "true" || "$NEW_VALUE" == "false" || "$NEW_VALUE" == "null" || "$NEW_VALUE" =~ ^[0-9]+$ ]]; then
+ # Boolean, null, or numeric values don't need quotes
+ echo "${VAR_NAME}=${NEW_VALUE}" >> "$TEMP_FILE"
+ else
+ # String values should be quoted
+ echo "${VAR_NAME}=\"${NEW_VALUE}\"" >> "$TEMP_FILE"
+ fi
+ else
+ echo "$line" >> "$TEMP_FILE"
+ fi
+ done < "$ENV_FILE"
+
+ # Replace original file
+ mv "$TEMP_FILE" "$ENV_FILE"
+
+ # Set permissions
+ chmod 755 "$ENV_FILE"
+ chown abc:abc "$ENV_FILE"
+
+ log "Updated ${VAR_NAME} to ${NEW_VALUE} in environment file"
+}
+
+# Setup repositories
+$(which cat) >> /etc/apk/repositories << EOF; $(echo)
http://dl-cdn.alpinelinux.org/alpine/edge/testing
EOF
@@ -51,7 +124,7 @@ log "**** install build packages from requirements ****" && \
done
log "**** install pip packages ****"
- pip3 install --no-cache-dir --break-system-packages -U apprise &>/dev/null
+ pip3 install --break-system-packages --no-cache-dir -U apprise &>/dev/null
log "**** install rclone ****"
$(which wget) -qO- https://rclone.org/install.sh | bash &>/dev/null
@@ -83,8 +156,13 @@ $(which cp) -r /app/sample/crypt_multi_tdrive-example.csv /system/uploader/sampl
$(which cp) -r /app/sample/uncrypt_multi_tdrive-example.csv /system/uploader/sample/uncrypt_multi_tdrive-example.csv
if [[ -f "${ENVA}" ]]; then
- source "${ENVA}"
+ # Validate the environment file
+ validate_env_file || {
+ log "Creating new environment file due to validation failure"
+ source "${SAMPLE}"
+ }
else
+ log "Environment file not found, using sample"
source "${SAMPLE}"
fi
@@ -109,7 +187,10 @@ tubesync/
EOF
fi
-$(which echo) -e "#-------------------------------------------------------
+# Create the environment file if needed while preserving existing values
+if [[ ! -f "${ENVA}" ]]; then
+ log "Setting up environment variables"
+ $(which echo) -e "#-------------------------------------------------------
# UPLOADER ENVIROMENTS
#-------------------------------------------------------
## USER VALUES
@@ -134,29 +215,29 @@ TRANSFERS=${TRANSFERS:-2}
## USER - SETTINGS
DRIVEUSEDSPACE=${DRIVEUSEDSPACE:-null}
FOLDER_DEPTH=${FOLDER_DEPTH:-1}
-FOLDER_PRIORITY=${FOLDER_PRIORITY:-null}
+FOLDER_PRIORITY=\"${FOLDER_PRIORITY:-null}\"
MIN_AGE_UPLOAD=${MIN_AGE_UPLOAD:-1}
## VFS - SETTINGS
VFS_REFRESH_ENABLE=${VFS_REFRESH_ENABLE:-true}
-MOUNT=${MOUNT:-mount:8554}
+MOUNT=\"${MOUNT:-mount:8554}\"
## LOG - SETTINGS
LOG_ENTRY=${LOG_ENTRY:-1000}
LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-null}
## AUTOSCAN - SETTINGS
-AUTOSCAN_URL=${AUTOSCAN_URL:-null}
-AUTOSCAN_USER=${AUTOSCAN_USER:-null}
-AUTOSCAN_PASS=${AUTOSCAN_PASS:-null}
+AUTOSCAN_URL=\"${AUTOSCAN_URL:-null}\"
+AUTOSCAN_USER=\"${AUTOSCAN_USER:-null}\"
+AUTOSCAN_PASS=\"${AUTOSCAN_PASS:-null}\"
## NOTIFICATION - SETTINGS
-NOTIFICATION_URL=${NOTIFICATION_URL:-null}
+NOTIFICATION_URL=\"${NOTIFICATION_URL:-null}\"
NOTIFICATION_LEVEL=${NOTIFICATION_LEVEL:-ALL}
-NOTIFICATION_SERVERNAME=${NOTIFICATION_SERVERNAME:-null}
+NOTIFICATION_SERVERNAME=\"${NOTIFICATION_SERVERNAME:-null}\"
## STRIPARR - SETTINGS
-STRIPARR_URL=${STRIPARR_URL:-null}
+STRIPARR_URL=\"${STRIPARR_URL:-null}\"
## LANGUAGE MESSAGES
LANGUAGE=${LANGUAGE:-en}
@@ -164,6 +245,10 @@ LANGUAGE=${LANGUAGE:-en}
#-------------------------------------------------------
# UPLOADER ENVIROMENTS
#-------------------------------------------------------" > "${ENVA}"
+ log "Environment file created"
+else
+ log "Using existing environment file"
+fi
if [[ -f "${ENDCONFIG}" ]]; then $(which rm) -f "${ENDCONFIG}"; fi
if [[ -f "${PAUSE}" ]]; then $(which rm) -f "${PAUSE}"; fi
@@ -184,20 +269,38 @@ if [[ ! -f "${DATABASE}" ]]; then
sqlite3write "CREATE TABLE upload_keys(time DATETIME DEFAULT (datetime('now','localtime')), key TEXT, used NUMERIC, active INTEGER);" &>/dev/null
sqlite3write "CREATE TABLE upload_queue(time DATETIME DEFAULT (datetime('now','localtime')), drive TEXT, filedir TEXT, filebase TEXT PRIMARY KEY, filesize TEXT, metadata INTEGER);" &>/dev/null
sqlite3write "CREATE TABLE uploads(drive TEXT, filedir TEXT, filebase TEXT PRIMARY KEY, filesize TEXT, logfile TEXT, gdsa TEXT);" &>/dev/null
- sqlite3write "CREATE TABLE completed_uploads(drive TEXT, filedir TEXT, filebase TEXT, filesize TEXT, gdsa TEXT, starttime NUMERIC, endtime NUMERIC, status NUMERIC, error TEXT);" &>/dev/null
+ sqlite3write "CREATE TABLE completed_uploads(drive TEXT, filedir TEXT, filebase TEXT, filesize TEXT, filesize_bytes INTEGER, gdsa TEXT, starttime NUMERIC, endtime NUMERIC, status NUMERIC, error TEXT);" &>/dev/null
sqlite3write "CREATE INDEX idx_completed_endtime ON completed_uploads (endtime);" &>/dev/null
sqlite3write "CREATE INDEX idx_queue_time ON upload_queue (time);" &>/dev/null
+ log "Database created with all required tables"
fi
-# UPDATE UPLOADER DATEBASE
+# UPDATE UPLOADER DATEBASE SCHEMA
sqlite3write "CREATE TABLE IF NOT EXISTS upload_keys(time DATETIME DEFAULT (datetime('now','localtime')), key TEXT, used NUMERIC, active INTEGER);" &>/dev/null
+# Check if metadata column exists in upload_queue
DATABASEMETA=$(sqlite3read "PRAGMA table_info('upload_queue')" 2>/dev/null | $(which grep) -qE "metadata" && echo true || echo false)
if [[ "${DATABASEMETA}" == "false" ]]; then
+ log "Adding metadata column to upload_queue table"
sqlite3write "DROP TABLE upload_queue" &>/dev/null
sqlite3write "CREATE TABLE upload_queue(time DATETIME DEFAULT (datetime('now','localtime')), drive TEXT, filedir TEXT, filebase TEXT PRIMARY KEY, filesize TEXT, metadata INTEGER);" &>/dev/null
fi
+# Check if filesize_bytes column exists in completed_uploads
+DATABASEBYTES=$(sqlite3read "PRAGMA table_info('completed_uploads')" 2>/dev/null | $(which grep) -qE "filesize_bytes" && echo true || echo false)
+if [[ "${DATABASEBYTES}" == "false" ]]; then
+ log "Adding filesize_bytes column to completed_uploads table"
+ sqlite3write "ALTER TABLE completed_uploads ADD COLUMN filesize_bytes INTEGER;" &>/dev/null
+ # Update existing records with estimated size in bytes
+ sqlite3write "UPDATE completed_uploads SET filesize_bytes = (CASE
+ WHEN filesize LIKE '%B' THEN CAST(REPLACE(filesize, 'B', '') AS INTEGER)
+ WHEN filesize LIKE '%KB' OR filesize LIKE '%KiB' THEN CAST(REPLACE(REPLACE(REPLACE(filesize, 'KB', ''), 'KiB', ''), ' ', '') AS FLOAT) * 1024
+ WHEN filesize LIKE '%MB' OR filesize LIKE '%MiB' THEN CAST(REPLACE(REPLACE(REPLACE(filesize, 'MB', ''), 'MiB', ''), ' ', '') AS FLOAT) * 1024 * 1024
+ WHEN filesize LIKE '%GB' OR filesize LIKE '%GiB' THEN CAST(REPLACE(REPLACE(REPLACE(filesize, 'GB', ''), 'GiB', ''), ' ', '') AS FLOAT) * 1024 * 1024 * 1024
+ WHEN filesize LIKE '%TB' OR filesize LIKE '%TiB' THEN CAST(REPLACE(REPLACE(REPLACE(filesize, 'TB', ''), 'TiB', ''), ' ', '') AS FLOAT) * 1024 * 1024 * 1024 * 1024
+ ELSE 0 END);" &>/dev/null
+fi
+
# UPLOADER KEYS
CHECKKEYS=$(sqlite3read "SELECT COUNT(*) FROM upload_keys;" 2>/dev/null)
if [[ "${CHECKKEYS}" -lt "1" && "${CHECKTYPE}" == "drive" ]]; then
@@ -333,4 +436,4 @@ We have take some code from :
------------------------------"
-#### END OF FILE ####
+#### END OF FILE ####
\ No newline at end of file
diff --git a/apps/docker-uploader/root/var/www/html/css/style.css b/apps/docker-uploader/root/var/www/html/css/style.css
deleted file mode 100755
index ab9c8b1d95..0000000000
--- a/apps/docker-uploader/root/var/www/html/css/style.css
+++ /dev/null
@@ -1,70 +0,0 @@
-table {
- font-size: small;
-}
-
-#uploadsTable > thead > tr > td.tl-w-20 {
- width:20%;
-}
-
-#info_badges > div {
- padding: 3px;
-}
-
-#info_badges > div > span.badge {
- height: 20px;
- min-width: 24px;
-}
-
-#control > span:hover {
- opacity:0.8;
- background-color: Black;
- cursor: pointer;
-}
-
-span#clnHist {
- font-size: 10px;
- padding: 5px;
-}
-span#clnHist:hover {
- font-size: 10px;
- text-decoration: underline;
- cursor: pointer;
-}
-
-@media only screen and (max-width: 800px) {
- .truncate {
- width:100%;
- white-space: nowrap !important;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .progress {
- height: 2px;
- font-size:0;
- }
-
- #uploadsTable > thead > tr > td.tl-w-20 {
- width: 100%;
- }
- /** THANKS: https://elvery.net/demo/responsive-tables/#css-1 **/
- #no-more-tables table,
- #no-more-tables thead,
- #no-more-tables tbody,
- #no-more-tables th,
- #no-more-tables td,
- #no-more-tables tr {
- display: block;
- }
- #no-more-tables thead tr {
- position: absolute;
- top: -9999px;
- left: -9999px;
- }
- #no-more-tables tr { border-bottom: 1px solid #ccc; }
- #no-more-tables td {
- border: none;
- position: relative;
- white-space: normal;
- text-align:left;
- }
-}
diff --git a/apps/docker-uploader/root/var/www/html/css/styles.css b/apps/docker-uploader/root/var/www/html/css/styles.css
new file mode 100644
index 0000000000..9fac87bf5a
--- /dev/null
+++ b/apps/docker-uploader/root/var/www/html/css/styles.css
@@ -0,0 +1,2416 @@
+/* Modern Docker Uploader Dashboard - Sidebar Layout v5.0 */
+
+:root {
+ /* Base Colors - Default Dark Theme */
+ --bg-primary: radial-gradient(
+ circle,
+ #3a3a3a,
+ #2d2d2d,
+ #202020,
+ #141414,
+ #000000
+ );
+ --bg-secondary: #252525;
+ --bg-tertiary: #3a3a3a;
+ --bg-card: #252525;
+ --text-primary: #ddd;
+ --text-secondary: #ddd;
+ --text-tertiary: #999;
+ --border-color: #3a3a3a;
+
+ /* Theme-specific colors - default dark */
+ --accent-color: rgb(170, 170, 170);
+ --accent-light: rgba(255, 255, 255, 0.45);
+ --accent-dark: #7a7a7a;
+ --accent-transparent: rgba(170, 170, 170, 0.15);
+ --accent-rgb: 170, 170, 170;
+
+ /* Status Colors */
+ --success: #10b981;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --info: #3b82f6;
+
+ /* Layout */
+ --sidebar-width: 260px;
+ --topbar-height: 70px;
+
+ /* Spacing */
+ --space-xs: 0.25rem;
+ --space-sm: 0.5rem;
+ --space-md: 1rem;
+ --space-lg: 1.5rem;
+ --space-xl: 2rem;
+ --space-xxl: 3rem;
+
+ /* Transitions */
+ --transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Border Radius */
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 16px;
+ --radius-xl: 20px;
+
+ /* Shadows */
+ --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1);
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
+ --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.15);
+ --shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.2);
+}
+
+/* Theme Variations */
+[data-theme="aquamarine"] {
+ --bg-primary: #0b3161;
+ --bg-secondary: #265c74;
+ --bg-tertiary: #47918a;
+ --bg-card: #265c74;
+ --accent-color: #009688;
+ --accent-light: #36e7d6;
+ --accent-dark: #12afa0;
+ --accent-transparent: rgba(0, 150, 136, 0.15);
+ --accent-rgb: 0, 150, 136;
+}
+
+[data-theme="dark"] {
+ /* Using new dark theme colors */
+ --bg-primary: radial-gradient(
+ circle,
+ #3a3a3a,
+ #2d2d2d,
+ #202020,
+ #141414,
+ #000000
+ );
+ --bg-secondary: #252525;
+ --bg-tertiary: #3a3a3a;
+ --bg-card: #252525;
+ --accent-color: rgb(170, 170, 170);
+ --accent-light: rgba(255, 255, 255, 0.45);
+ --accent-dark: #7a7a7a;
+ --accent-transparent: rgba(170, 170, 170, 0.15);
+ --accent-rgb: 170, 170, 170;
+ --text-primary: #ddd;
+ --text-secondary: #ddd;
+ --text-tertiary: #999;
+ --border-color: #3a3a3a;
+}
+
+[data-theme="organizr"] {
+ --bg-primary: #1f1f1f;
+ --bg-secondary: #2a2a2a;
+ --bg-tertiary: #333333;
+ --bg-card: #2a2a2a;
+ --accent-color: #2cabe3;
+ --accent-light: #3cc5ff;
+ --accent-dark: #298fbc;
+ --accent-transparent: rgba(44, 171, 227, 0.15);
+ --accent-rgb: 44, 171, 227;
+}
+
+[data-theme="nord"] {
+ --bg-primary: #2e3440;
+ --bg-secondary: #3b4252;
+ --bg-tertiary: #434c5e;
+ --bg-card: #3b4252;
+ --accent-color: #88c0d0;
+ --accent-light: #a3d5e0;
+ --accent-dark: #6a9daf;
+ --accent-transparent: rgba(136, 192, 208, 0.15);
+ --accent-rgb: 136, 192, 208;
+}
+
+[data-theme="overseerr"] {
+ --bg-primary: hsl(221, 39%, 11%);
+ --bg-secondary: #1f2937;
+ --bg-tertiary: #374151;
+ --bg-card: #1f2937;
+ --accent-color: #a78bfa;
+ --accent-light: #c4b5fd;
+ --accent-dark: #8b5cf6;
+ --accent-transparent: rgba(167, 139, 250, 0.15);
+ --accent-rgb: 167, 139, 250;
+}
+
+[data-theme="onedark"] {
+ --bg-primary: #282c34;
+ --bg-secondary: #21252b;
+ --bg-tertiary: #2c313a;
+ --bg-card: #21252b;
+ --accent-color: #61afef;
+ --accent-light: #87c5f2;
+ --accent-dark: #528bcc;
+ --accent-transparent: rgba(97, 175, 239, 0.15);
+ --accent-rgb: 97, 175, 239;
+}
+
+[data-theme="hotline"] {
+ --bg-primary: #155fa5;
+ --bg-secondary: #4a5ba5;
+ --bg-tertiary: #5e61ab;
+ --bg-card: #4a5ba5;
+ --accent-color: #f98dc9;
+ --accent-light: #ffb3de;
+ --accent-dark: #e666b3;
+ --accent-transparent: rgba(249, 141, 201, 0.15);
+ --accent-rgb: 249, 141, 201;
+}
+
+[data-theme="maroon"] {
+ --bg-primary: #220a25;
+ --bg-secondary: #3d1a33;
+ --bg-tertiary: #4c2133;
+ --bg-card: #3d1a33;
+ --accent-color: #a21c65;
+ --accent-light: #c72979;
+ --accent-dark: #841652;
+ --accent-transparent: rgba(162, 28, 101, 0.15);
+ --accent-rgb: 162, 28, 101;
+}
+
+[data-theme="dracula"] {
+ --bg-primary: #282a36;
+ --bg-secondary: #1e2029;
+ --bg-tertiary: #44475a;
+ --bg-card: #1e2029;
+ --accent-color: #bd93f9;
+ --accent-light: #d4b5ff;
+ --accent-dark: #a87cd5;
+ --accent-transparent: rgba(189, 147, 249, 0.15);
+ --accent-rgb: 189, 147, 249;
+}
+
+[data-theme="plex"] {
+ --bg-primary: #1a1a1a;
+ --bg-secondary: #282828;
+ --bg-tertiary: #323232;
+ --bg-card: #282828;
+ --accent-color: #e5a00d;
+ --accent-light: #f5b836;
+ --accent-dark: #cc7b19;
+ --accent-transparent: rgba(229, 160, 13, 0.15);
+ --accent-rgb: 229, 160, 13;
+}
+
+[data-theme="space-gray"] {
+ --bg-primary: #253237;
+ --bg-secondary: #2f3d42;
+ --bg-tertiary: #3b4a51;
+ --bg-card: #2f3d42;
+ --accent-color: #81a6b7;
+ --accent-light: #9ebdcc;
+ --accent-dark: #607d8b;
+ --accent-transparent: rgba(129, 166, 183, 0.15);
+ --accent-rgb: 129, 166, 183;
+}
+
+[data-theme="hotpink"] {
+ --bg-primary: #004249;
+ --bg-secondary: #123c5c;
+ --bg-tertiary: #204c80;
+ --bg-card: #123c5c;
+ --accent-color: #fb3f62;
+ --accent-light: #ff6683;
+ --accent-dark: #e12b4f;
+ --accent-transparent: rgba(251, 63, 98, 0.15);
+ --accent-rgb: 251, 63, 98;
+}
+
+/* Reset & Base Styles */
+*,
+*::before,
+*::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+ "Helvetica Neue", Arial, sans-serif;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ overflow-x: hidden;
+ min-height: 100vh;
+}
+
+a {
+ color: var(--accent-color);
+ text-decoration: none;
+ transition: color var(--transition);
+}
+
+a:hover {
+ color: var(--accent-light);
+}
+
+/* Scrollbar Styling */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-secondary);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--accent-color);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--accent-light);
+}
+
+/* Mobile Menu Toggle */
+.mobile-menu-toggle {
+ position: fixed;
+ top: var(--space-md);
+ left: var(--space-md);
+ z-index: 1002;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ width: 44px;
+ height: 44px;
+ border-radius: var(--radius-md);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all var(--transition);
+ box-shadow: var(--shadow-md);
+}
+
+.mobile-menu-toggle:hover {
+ background: var(--accent-color);
+ border-color: var(--accent-color);
+ transform: scale(1.05);
+}
+
+.mobile-menu-toggle i {
+ font-size: 1.25rem;
+}
+
+/* Sidebar */
+.sidebar {
+ position: fixed;
+ left: 0;
+ top: 0;
+ width: var(--sidebar-width);
+ height: 100vh;
+ background: var(--bg-secondary);
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ z-index: 1000;
+ transition: transform var(--transition);
+ box-shadow: var(--shadow-lg);
+}
+
+.sidebar-header {
+ padding: var(--space-xl) var(--space-lg);
+ background: var(--bg-secondary);
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+}
+
+.logo-image {
+ width: 36px;
+ height: 36px;
+ object-fit: contain;
+}
+
+.logo-text {
+ font-size: 1.5rem;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+}
+
+.version-badge {
+ display: inline-block;
+ background: var(--accent-transparent);
+ color: var(--accent-color);
+ padding: 0.25rem 0.75rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ border: 1px solid var(--accent-color);
+ margin-top: var(--space-xs);
+}
+
+.sidebar-nav {
+ flex: 1;
+ padding: var(--space-lg) 0;
+ overflow-y: auto;
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-md) var(--space-lg);
+ color: var(--text-secondary);
+ transition: all var(--transition);
+ position: relative;
+ margin: 0 var(--space-sm);
+ border-radius: var(--radius-md);
+}
+
+.nav-badge {
+ margin-left: auto;
+ font-size: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 12px;
+ background: rgba(239, 68, 68, 0.2);
+ color: #ef4444;
+ white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.nav-badge.failed-badge {
+ background: rgba(239, 68, 68, 0.2);
+ color: #ef4444;
+}
+
+.nav-badge i {
+ font-size: 0.65rem;
+}
+
+.nav-item::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 4px;
+ height: 0;
+ background: var(--accent-color);
+ border-radius: 0 4px 4px 0;
+ transition: height var(--transition);
+}
+
+.nav-item:hover {
+ color: var(--text-primary);
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.nav-item.active {
+ color: var(--accent-color);
+ background: var(--accent-transparent);
+ font-weight: 600;
+}
+
+.nav-item.active::before {
+ height: 60%;
+}
+
+.nav-item i {
+ font-size: 1.25rem;
+ width: 24px;
+ text-align: center;
+}
+
+.sidebar-footer {
+ padding: var(--space-lg);
+ border-top: 1px solid var(--border-color);
+}
+
+.version-check {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-sm);
+ padding: var(--space-md);
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-color);
+}
+
+.version-number {
+ font-size: 0.875rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.version-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ transition: all var(--transition);
+}
+
+.version-status.checking {
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text-tertiary);
+}
+
+.version-status.up-to-date {
+ background: rgba(16, 185, 129, 0.15);
+ color: #10b981;
+ border: 1px solid rgba(16, 185, 129, 0.3);
+}
+
+.version-status.update-available {
+ background: rgba(245, 158, 11, 0.15);
+ color: #f59e0b;
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ cursor: pointer;
+}
+
+.version-status.update-available:hover {
+ background: rgba(245, 158, 11, 0.25);
+}
+
+.version-status.develop {
+ background: rgba(139, 92, 246, 0.15);
+ color: #8b5cf6;
+ border: 1px solid rgba(139, 92, 246, 0.3);
+}
+
+.theme-toggle-btn {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+ padding: var(--space-md);
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition);
+ font-size: 0.95rem;
+}
+
+.theme-toggle-btn:hover {
+ background: var(--accent-color);
+ color: white;
+ border-color: var(--accent-color);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+/* Main Content */
+.main-content {
+ margin-left: var(--sidebar-width);
+ min-height: 100vh;
+ background: var(--bg-primary);
+ transition: margin-left var(--transition);
+}
+
+/* Top Bar */
+.top-bar {
+ height: var(--topbar-height);
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 var(--space-xl);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ box-shadow: var(--shadow-sm);
+}
+
+.top-bar-left {
+ flex: 1;
+}
+
+.page-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ margin: 0;
+ letter-spacing: -0.5px;
+}
+
+.top-bar-right {
+ display: flex;
+ align-items: center;
+ gap: var(--space-lg);
+}
+
+/* Theme Dropdown */
+.theme-dropdown {
+ position: relative;
+}
+
+.theme-toggle-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.theme-toggle-btn:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: var(--accent-color);
+ color: var(--accent-color);
+ transform: translateY(-1px);
+}
+
+.theme-dropdown-menu {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ min-width: 200px;
+ max-height: 400px;
+ overflow-y: auto;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-10px);
+ transition: all 0.2s ease;
+ z-index: 1000;
+}
+
+.theme-dropdown-menu.active {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.theme-dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-md) var(--space-lg);
+ cursor: pointer;
+ transition: all var(--transition);
+ position: relative;
+}
+
+.theme-dropdown-item:hover {
+ background: var(--bg-tertiary);
+}
+
+.theme-dropdown-item.active {
+ background: var(--accent-transparent);
+}
+
+.theme-dropdown-item .theme-check {
+ margin-left: auto;
+ color: var(--accent-color);
+ opacity: 0;
+ transition: opacity var(--transition);
+}
+
+.theme-dropdown-item.active .theme-check {
+ opacity: 1;
+}
+
+.theme-dropdown-item span {
+ flex: 1;
+ font-size: 0.9rem;
+ color: var(--text-primary);
+}
+
+/* Settings Styled Select (reuses theme dropdown menu layout) */
+.settings-dropdown {
+ position: relative;
+ width: 100%;
+}
+
+.settings-select-btn {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ height: 44px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 0 var(--space-md);
+ transition: all var(--transition);
+}
+
+.settings-select-btn:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: var(--accent-color);
+ color: var(--accent-color);
+ transform: translateY(-1px);
+}
+
+.settings-select-btn .selected-text {
+ font-size: 0.95rem;
+ color: var(--text-primary);
+}
+
+.settings-dropdown-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ min-width: 100%;
+ max-height: 280px;
+ overflow-y: auto;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-8px);
+ transition: all 0.2s ease;
+ z-index: 1000;
+}
+
+.settings-dropdown-menu.active {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.settings-dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-md) var(--space-lg);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.settings-dropdown-item:hover {
+ background: var(--bg-tertiary);
+}
+
+.settings-dropdown-item.active {
+ background: var(--accent-transparent);
+}
+
+.status-indicator {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-md);
+ background: var(--bg-tertiary);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-color);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.status-indicator:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.2);
+ transform: translateY(-1px);
+}
+
+.status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--success);
+ animation: pulse 2s infinite;
+ transition: background var(--transition);
+}
+
+/* Status States */
+.status-indicator.status-active .status-dot {
+ background: #10b981;
+ box-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
+}
+
+.status-indicator.status-paused .status-dot {
+ background: #f59e0b;
+ box-shadow: 0 0 10px rgba(245, 158, 11, 0.5);
+ animation: none;
+}
+
+.status-indicator.status-stopped .status-dot {
+ background: #ef4444;
+ box-shadow: 0 0 10px rgba(239, 68, 68, 0.5);
+ animation: none;
+}
+
+.status-indicator.status-error .status-dot {
+ background: #dc2626;
+ box-shadow: 0 0 10px rgba(220, 38, 38, 0.5);
+ animation: blink 1s infinite;
+}
+
+@keyframes blink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.2;
+ }
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+.status-text {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ transition: color var(--transition);
+}
+
+.status-indicator.status-active .status-text {
+ color: #10b981;
+}
+
+.status-indicator.status-paused .status-text {
+ color: #f59e0b;
+}
+
+.status-indicator.status-stopped .status-text {
+ color: #ef4444;
+}
+
+.status-indicator.status-error .status-text {
+ color: #dc2626;
+}
+
+.icon-btn {
+ width: 44px;
+ height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.icon-btn:hover {
+ background: var(--accent-color);
+ border-color: var(--accent-color);
+ transform: scale(1.05);
+}
+
+/* Content Sections */
+.content-section {
+ display: none;
+ padding: var(--space-xxl);
+ animation: fadeIn 0.3s ease;
+}
+
+.content-section.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Stats Grid */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: var(--space-lg);
+ margin-bottom: var(--space-xxl);
+}
+
+.stat-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: var(--space-xl);
+ transition: all var(--transition);
+ position: relative;
+ overflow: hidden;
+}
+
+.stat-card::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+ background: var(--accent-color);
+}
+
+.stat-card::after {
+ content: "";
+ position: absolute;
+ top: -50%;
+ right: -50%;
+ width: 200%;
+ height: 200%;
+ background: radial-gradient(
+ circle,
+ rgba(255, 255, 255, 0.05) 0%,
+ transparent 70%
+ );
+ pointer-events: none;
+ transition: transform 0.6s ease;
+}
+
+.stat-card:hover::after {
+ transform: translate(-25%, -25%);
+}
+
+/* Stat Card Color Variants */
+.stat-card-orange::before {
+ background: linear-gradient(90deg, #ff9800, #ff5722);
+}
+
+.stat-card-blue::before {
+ background: linear-gradient(90deg, #2196f3, #00bcd4);
+}
+
+.stat-card-purple::before {
+ background: linear-gradient(90deg, #9c27b0, #e91e63);
+}
+
+.stat-card-green::before {
+ background: linear-gradient(90deg, #4caf50, #8bc34a);
+}
+
+.stat-card:hover {
+ transform: translateY(-5px);
+ box-shadow: var(--shadow-lg);
+ border-color: var(--accent-color);
+}
+
+.stat-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-md);
+}
+
+.stat-icon {
+ width: 60px;
+ height: 60px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-md);
+ position: relative;
+ overflow: hidden;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.stat-icon i {
+ font-size: 1.75rem;
+ color: white;
+ position: relative;
+ z-index: 1;
+}
+
+/* Colorful Gradient Icons (Fixed Colors) */
+.gradient-orange {
+ background: linear-gradient(135deg, #ff9800 0%, #ff5722 100%);
+}
+
+.gradient-blue {
+ background: linear-gradient(135deg, #2196f3 0%, #00bcd4 100%);
+}
+
+.gradient-purple {
+ background: linear-gradient(135deg, #9c27b0 0%, #e91e63 100%);
+}
+
+.gradient-green {
+ background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
+}
+
+.gradient-red {
+ background: linear-gradient(135deg, #f44336 0%, #e91e63 100%);
+}
+
+.gradient-indigo {
+ background: linear-gradient(135deg, #3f51b5 0%, #5c6bc0 100%);
+}
+
+.gradient-cyan {
+ background: linear-gradient(135deg, #00bcd4 0%, #26c6da 100%);
+}
+
+/* Stat Badges */
+.stat-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.375rem 0.75rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.badge-pulse {
+ background: linear-gradient(135deg, #ff9800, #ff5722);
+ color: white;
+ animation: pulse 2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 0.9;
+ transform: scale(1.02);
+ }
+}
+
+.badge-info {
+ background: linear-gradient(135deg, #2196f3, #00bcd4);
+ color: white;
+}
+
+.badge-warning {
+ background: linear-gradient(135deg, #ff9800, #ffc107);
+ color: white;
+}
+
+.badge-success {
+ background: linear-gradient(135deg, #4caf50, #8bc34a);
+ color: white;
+}
+
+.badge-live {
+ background: linear-gradient(135deg, #f44336, #e91e63);
+ color: white;
+ margin-left: 0.75rem;
+ animation: pulse 2s ease-in-out infinite;
+}
+
+.badge-live i {
+ animation: blink 1.5s ease-in-out infinite;
+}
+
+@keyframes blink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.3;
+ }
+}
+
+/* Mini Badges */
+.mini-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ margin-top: 0.5rem;
+}
+
+/* Overview Badges - Enhanced Styling */
+.overview-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.375rem 0.875rem;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.75px;
+ margin-top: 0.5rem;
+ box-shadow: 0 3px 12px rgba(0, 0, 0, 0.25);
+ transition: all 0.3s ease;
+ border: 1.5px solid transparent;
+}
+
+.overview-badge:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 5px 20px rgba(0, 0, 0, 0.35);
+}
+
+.overview-badge i {
+ font-size: 0.75rem;
+}
+
+/* Success Badge with Glow */
+.badge-success-glow {
+ background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
+ color: white;
+ border-color: #10b981;
+ box-shadow: 0 3px 12px rgba(16, 185, 129, 0.4),
+ 0 0 20px rgba(16, 185, 129, 0.2);
+ animation: successPulse 3s ease-in-out infinite;
+}
+
+.badge-success-glow:hover {
+ box-shadow: 0 5px 20px rgba(16, 185, 129, 0.5),
+ 0 0 30px rgba(16, 185, 129, 0.3);
+}
+
+@keyframes successPulse {
+ 0%,
+ 100% {
+ box-shadow: 0 3px 12px rgba(16, 185, 129, 0.4),
+ 0 0 20px rgba(16, 185, 129, 0.2);
+ }
+ 50% {
+ box-shadow: 0 4px 16px rgba(16, 185, 129, 0.5),
+ 0 0 25px rgba(16, 185, 129, 0.3);
+ }
+}
+
+/* Info Badge with Glow */
+.badge-info-glow {
+ background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%);
+ color: white;
+ border-color: #3b82f6;
+ box-shadow: 0 3px 12px rgba(59, 130, 246, 0.4),
+ 0 0 20px rgba(59, 130, 246, 0.2);
+ animation: infoPulse 3s ease-in-out infinite;
+}
+
+.badge-info-glow:hover {
+ box-shadow: 0 5px 20px rgba(59, 130, 246, 0.5),
+ 0 0 30px rgba(59, 130, 246, 0.3);
+}
+
+@keyframes infoPulse {
+ 0%,
+ 100% {
+ box-shadow: 0 3px 12px rgba(59, 130, 246, 0.4),
+ 0 0 20px rgba(59, 130, 246, 0.2);
+ }
+ 50% {
+ box-shadow: 0 4px 16px rgba(59, 130, 246, 0.5),
+ 0 0 25px rgba(59, 130, 246, 0.3);
+ }
+}
+
+/* Warning Badge with Glow */
+.badge-warning-glow {
+ background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
+ color: white;
+ border-color: #f59e0b;
+ box-shadow: 0 3px 12px rgba(245, 158, 11, 0.4),
+ 0 0 20px rgba(245, 158, 11, 0.2);
+ animation: warningPulse 3s ease-in-out infinite;
+}
+
+.badge-warning-glow:hover {
+ box-shadow: 0 5px 20px rgba(245, 158, 11, 0.5),
+ 0 0 30px rgba(245, 158, 11, 0.3);
+}
+
+@keyframes warningPulse {
+ 0%,
+ 100% {
+ box-shadow: 0 3px 12px rgba(245, 158, 11, 0.4),
+ 0 0 20px rgba(245, 158, 11, 0.2);
+ }
+ 50% {
+ box-shadow: 0 4px 16px rgba(245, 158, 11, 0.5),
+ 0 0 25px rgba(245, 158, 11, 0.3);
+ }
+}
+
+.badge-outline-orange {
+ background: rgba(255, 152, 0, 0.1);
+ border: 1px solid #ff9800;
+ color: #ff9800;
+}
+
+.badge-outline-blue {
+ background: rgba(33, 150, 243, 0.1);
+ border: 1px solid #2196f3;
+ color: #2196f3;
+}
+
+.badge-outline-purple {
+ background: rgba(156, 39, 176, 0.1);
+ border: 1px solid #9c27b0;
+ color: #9c27b0;
+}
+
+.badge-outline-green {
+ background: rgba(76, 175, 80, 0.1);
+ border: 1px solid #4caf50;
+ color: #4caf50;
+}
+
+.stat-label {
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text-tertiary);
+ font-weight: 600;
+ margin-bottom: var(--space-sm);
+}
+
+.stat-body {
+ margin-bottom: var(--space-md);
+}
+
+.stat-value {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1;
+ margin-bottom: var(--space-sm);
+}
+
+.stat-subtitle {
+ font-size: 0.875rem;
+ color: var(--text-tertiary);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.stat-subtitle i {
+ color: var(--accent-color);
+ font-size: 0.875rem;
+}
+
+.stat-footer {
+ margin-top: var(--space-lg);
+ padding-top: var(--space-lg);
+ border-top: 1px solid var(--border-color);
+}
+
+.stat-footer-mini {
+ margin-top: var(--space-md);
+}
+
+/* Mini Progress Bar */
+.stat-progress {
+ margin-top: var(--space-md);
+}
+
+.progress-mini {
+ height: 6px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.progress-bar-mini {
+ height: 100%;
+ border-radius: 999px;
+ transition: width 0.5s ease;
+ box-shadow: 0 0 10px currentColor;
+}
+
+.control-button {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-lg);
+ background: var(--success);
+ color: white;
+ border-radius: var(--radius-md);
+ border: none;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.control-button:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.control-button.bg-success {
+ background: var(--success);
+}
+
+.control-button.bg-danger {
+ background: var(--danger);
+}
+
+/* Card Styles */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-xl);
+ overflow: hidden;
+ transition: all var(--transition);
+}
+
+.card:hover {
+ border-color: rgba(255, 255, 255, 0.15);
+ box-shadow: var(--shadow-md);
+}
+
+.card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-xl);
+ border-bottom: 1px solid var(--border-color);
+ background: rgba(255, 255, 255, 0.02);
+ position: relative;
+}
+
+.card-title {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin: 0;
+}
+
+.card-title i {
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--accent-transparent);
+ border-radius: var(--radius-md);
+ color: var(--accent-color);
+ font-size: 1.125rem;
+}
+
+.card-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+}
+
+.card-action-link {
+ font-size: 0.875rem;
+ color: var(--accent-color);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+ transition: all var(--transition);
+ font-weight: 500;
+}
+
+.card-action-link:hover {
+ color: var(--accent-light);
+ gap: var(--space-sm);
+}
+
+.card-body {
+ padding: var(--space-xl);
+}
+
+/* Overview Mini Cards - Horizontal Layout */
+.overview-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: var(--space-lg);
+ margin-bottom: var(--space-xxl);
+}
+
+.overview-mini-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: var(--space-lg) var(--space-xl);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-md);
+ transition: all var(--transition);
+ position: relative;
+ overflow: hidden;
+}
+
+.overview-mini-card::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: var(--accent-color);
+ opacity: 0;
+ transition: opacity var(--transition);
+}
+
+.overview-mini-card:hover {
+ transform: translateY(-3px);
+ box-shadow: var(--shadow-lg);
+ border-color: var(--accent-color);
+}
+
+.overview-mini-card:hover::before {
+ opacity: 1;
+}
+
+.overview-mini-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.overview-mini-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--text-tertiary);
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.overview-mini-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1;
+}
+
+/* Colored Values Matching Icon Gradients */
+.value-green {
+ color: #4caf50;
+}
+
+.value-blue {
+ color: #2196f3;
+}
+
+.value-orange {
+ color: #ff9800;
+}
+
+.value-purple {
+ color: #9c27b0;
+}
+
+.value-indigo {
+ color: #5c6bc0;
+}
+
+.value-red {
+ color: #f44336;
+}
+
+.value-cyan {
+ color: #00bcd4;
+}
+
+/* Mini Subtitle for Additional Info */
+.overview-mini-subtitle {
+ font-size: 0.7rem;
+ color: var(--text-tertiary);
+ margin-top: 0.375rem;
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.overview-mini-subtitle i {
+ font-size: 0.7rem;
+ color: var(--text-tertiary);
+}
+
+.overview-mini-icon {
+ width: 48px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-md);
+ flex-shrink: 0;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.overview-mini-icon i {
+ font-size: 1.5rem;
+ color: white;
+}
+
+/* Table Styles */
+.table-responsive {
+ overflow-x: auto;
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.01);
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table thead {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.table th {
+ padding: var(--space-lg) var(--space-md);
+ text-align: left;
+ font-weight: 600;
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-tertiary);
+ border-bottom: 2px solid var(--border-color);
+ position: relative;
+ vertical-align: middle;
+}
+
+.table th::after {
+ content: "";
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ width: 0;
+ height: 2px;
+ background: var(--accent-color);
+ transition: width var(--transition);
+}
+
+.table th:hover::after {
+ width: 100%;
+}
+
+.table td {
+ padding: var(--space-lg) var(--space-md);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
+ vertical-align: middle;
+}
+
+.table tbody tr {
+ transition: all var(--transition);
+}
+
+.table tbody tr:hover {
+ background: rgba(255, 255, 255, 0.05);
+ border-left: 3px solid var(--accent-color);
+}
+
+.no-uploads-message {
+ text-align: center !important;
+ padding: var(--space-xxl) !important;
+ color: var(--text-tertiary);
+ font-style: italic;
+ vertical-align: middle !important;
+}
+
+.active-uploads-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-sm);
+ color: white;
+ padding: var(--space-sm) var(--space-lg);
+ border-radius: 999px;
+ font-size: 0.875rem;
+ font-weight: 600;
+ border: 1px solid var(--accent-dark);
+}
+
+#active-count-badge {
+ background: transparent;
+ color: inherit;
+ padding: 0;
+ border-radius: 0;
+ font-size: 0.85rem;
+ min-width: 0;
+ text-align: inherit;
+ font-weight: 700;
+ border: none;
+}
+
+.action-button {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-lg);
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--accent-dark);
+ cursor: pointer;
+ transition: all var(--transition);
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.action-button:hover {
+ background: var(--accent-color);
+ color: white;
+ border-color: var(--accent-color);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+/* Progress Bars */
+.progress-container {
+ width: 100%;
+}
+
+.progress-info {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: var(--space-xs);
+ font-size: 0.875rem;
+}
+
+.progress-percentage {
+ font-weight: 600;
+ color: var(--accent-color);
+}
+
+.progress {
+ height: 8px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 999px;
+ overflow: hidden;
+}
+
+.progress-bar {
+ height: 100%;
+ background: var(--accent-color);
+ border-radius: 999px;
+ transition: width 0.3s ease;
+}
+
+.progress-bar.bg-success {
+ background: var(--success);
+}
+
+/* Settings Grid */
+.settings-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-xl);
+ margin-bottom: var(--space-xl);
+}
+
+.settings-grid .card {
+ transition: all var(--transition);
+ border: 1px solid var(--border-color);
+ background: var(--bg-card);
+ box-shadow: var(--shadow-sm);
+ overflow: visible; /* allow custom dropdown menus to extend outside the card */
+}
+
+.settings-grid .card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+ border-color: rgba(var(--accent-rgb), 0.3);
+}
+
+.settings-grid .card-header {
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ padding: var(--space-lg);
+}
+
+.settings-grid .card-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+}
+
+.settings-grid .card-title i {
+ font-size: 1rem;
+ color: var(--accent-color);
+}
+
+.settings-grid .card-body {
+ padding: var(--space-lg);
+}
+
+.theme-card {
+ grid-column: 1 / -1;
+}
+
+.settings-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-lg);
+}
+
+.unified-settings-form {
+ width: 100%;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ min-height: auto;
+}
+
+/* Extra spacing between stacked form groups (space between previous input and next label) */
+.form-group + .form-group {
+ margin-top: var(--space-xl);
+}
+
+.form-group label {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ text-align: left;
+}
+
+.form-control {
+ padding: var(--space-md);
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ transition: all var(--transition);
+ width: 100%;
+ max-width: none;
+}
+
+.form-control:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ background: var(--bg-tertiary);
+}
+
+/* Toggle Switch */
+.toggle-switch {
+ position: relative;
+ display: inline-block;
+ width: 48px;
+ height: 24px;
+}
+
+.toggle-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ transition: all 0.3s ease;
+ border-radius: 24px;
+}
+
+.toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 18px;
+ width: 18px;
+ left: 2px;
+ bottom: 2px;
+ background-color: var(--text-secondary);
+ transition: all 0.3s ease;
+ border-radius: 50%;
+}
+
+.toggle-switch input:checked + .toggle-slider {
+ background-color: var(--accent-color);
+ border-color: var(--accent-color);
+}
+
+.toggle-switch input:checked + .toggle-slider:before {
+ transform: translateX(24px);
+ background-color: white;
+}
+
+.toggle-switch input:focus + .toggle-slider {
+ box-shadow: 0 0 0 3px var(--accent-transparent);
+}
+
+.form-group.toggle-group {
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-md);
+ min-height: 48px;
+ margin-top: var(--space-md);
+ margin-bottom: var(--space-md);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--bg-secondary);
+ transition: all 0.2s ease;
+}
+
+.form-group.toggle-group label:first-child {
+ margin-bottom: 0;
+}
+
+.settings-save-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: var(--space-xl) 0;
+ margin-top: var(--space-lg);
+ border-top: 1px solid var(--border-color);
+}
+
+.settings-save-top {
+ justify-content: flex-end;
+ margin-top: 0;
+ margin-bottom: var(--space-xl);
+ border-top: none;
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.auto-save-indicator {
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Toast Notifications */
+.toast-container {
+ position: fixed;
+ top: 100px;
+ right: 20px;
+ z-index: 10000;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ max-width: 350px;
+}
+
+.toast {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-lg);
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ animation: slideInRight 0.3s ease-out;
+ min-width: 300px;
+ position: relative;
+ opacity: 0.7;
+}
+
+.toast.toast-success {
+ border-left: 4px solid var(--success);
+}
+
+.toast.toast-error {
+ border-left: 4px solid var(--danger);
+}
+
+.toast.toast-info {
+ border-left: 4px solid var(--info);
+}
+
+.toast.toast-warning {
+ border-left: 4px solid var(--warning);
+}
+
+.toast-icon {
+ font-size: 1.25rem;
+ flex-shrink: 0;
+}
+
+.toast-success .toast-icon {
+ color: var(--success);
+}
+
+.toast-error .toast-icon {
+ color: var(--danger);
+}
+
+.toast-info .toast-icon {
+ color: var(--info);
+}
+
+.toast-warning .toast-icon {
+ color: var(--warning);
+}
+
+.toast-content {
+ flex: 1;
+}
+
+.toast-title {
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.25rem;
+}
+
+.toast-message {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.toast-close {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ padding: 0.25rem;
+ font-size: 1rem;
+ opacity: 0.6;
+ transition: opacity var(--transition);
+}
+
+.toast-close:hover {
+ opacity: 1;
+}
+
+@keyframes slideInRight {
+ from {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideOutRight {
+ from {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+}
+
+.toast.removing {
+ animation: slideOutRight 0.3s ease-out forwards;
+}
+
+.btn-save-all {
+ min-width: 200px;
+ font-size: 1rem;
+ padding: var(--space-lg) var(--space-xl);
+}
+
+.btn {
+ padding: var(--space-md) var(--space-xl);
+ background: var(--accent-transparent);
+ color: white;
+ border: 1px solid var(--accent-dark);
+ border-radius: var(--radius-md);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition);
+ font-size: 0.9rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+}
+
+.btn:hover {
+ background: var(--accent-color);
+ color: white;
+ border-color: var(--accent-color);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn:active {
+ transform: translateY(0);
+}
+
+/* Theme Settings */
+.theme-card {
+ grid-column: 1 / -1;
+}
+
+.theme-section {
+ margin-bottom: 0;
+}
+
+.theme-section h3 {
+ font-size: 1.125rem;
+ margin-bottom: var(--space-md);
+ color: var(--text-primary);
+}
+
+.theme-section p {
+ color: var(--text-tertiary);
+ margin-bottom: var(--space-lg);
+ font-size: 0.95rem;
+}
+
+.theme-options {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: var(--space-md);
+}
+
+.theme-option {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-lg);
+ background: var(--bg-tertiary);
+ border: 2px solid transparent;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.theme-option:hover {
+ transform: translateY(-3px);
+ box-shadow: var(--shadow-md);
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+.theme-option.active {
+ border-color: var(--accent-color);
+ background: var(--accent-transparent);
+}
+
+.theme-dot {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ box-shadow: var(--shadow-sm);
+}
+
+.theme-dot.aquamarine {
+ background: #009688;
+}
+.theme-dot.dark {
+ background: #888888;
+}
+.theme-dot.organizr {
+ background: #2cabe3;
+}
+.theme-dot.nord {
+ background: #88c0d0;
+}
+.theme-dot.overseerr {
+ background: #a78bfa;
+}
+.theme-dot.onedark {
+ background: #61afef;
+}
+.theme-dot.hotline {
+ background: #f98dc9;
+}
+.theme-dot.maroon {
+ background: #a21c65;
+}
+.theme-dot.dracula {
+ background: #bd93f9;
+}
+.theme-dot.plex {
+ background: #e5a00d;
+}
+.theme-dot.space-gray {
+ background: #81a6b7;
+}
+.theme-dot.hotpink {
+ background: #fb3f62;
+}
+
+.theme-option span {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ text-align: center;
+}
+
+/* Footer */
+.footer {
+ padding: var(--space-xl);
+ text-align: center;
+ color: var(--text-tertiary);
+ font-size: 0.875rem;
+ border-top: 1px solid var(--border-color);
+ margin-top: var(--space-xxl);
+}
+
+.footer p {
+ margin-bottom: var(--space-sm);
+}
+
+/* Status Message */
+.status-message {
+ position: fixed;
+ top: 90px;
+ right: var(--space-xl);
+ z-index: 2000;
+ padding: var(--space-lg) var(--space-xl);
+ background: var(--success);
+ color: white;
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-lg);
+ display: none;
+ animation: slideInRight 0.3s ease;
+}
+
+@keyframes slideInRight {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+.status-message.error {
+ background: var(--danger);
+}
+
+/* Pagination */
+.pagination-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--bg-card);
+ border-top: 1px solid var(--border-color);
+ padding: var(--space-md) var(--space-lg);
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
+}
+
+.pagination-container {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: var(--space-lg);
+ margin-top: var(--space-lg);
+ border-top: 1px solid var(--border-color);
+}
+
+.pagination-container.pagination-standalone {
+ padding-top: 0;
+ margin-top: 0;
+ border-top: 0;
+ width: 100%;
+}
+
+.pagination {
+ display: flex;
+ gap: var(--space-xs);
+ list-style: none;
+}
+
+.page-item {
+ list-style: none;
+}
+
+.page-link {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 40px;
+ height: 40px;
+ padding: 0 var(--space-md);
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-color);
+ transition: all var(--transition);
+ font-weight: 600;
+}
+
+.page-link:hover {
+ background: var(--accent-transparent);
+ color: var(--accent-color);
+ border-color: var(--accent-color);
+}
+
+.page-item.active .page-link {
+ background: var(--accent-color);
+ color: white;
+ border-color: var(--accent-color);
+}
+
+/* Utility Classes */
+.d-none {
+ display: none !important;
+}
+.d-flex {
+ display: flex !important;
+}
+.d-lg-table-cell {
+ display: table-cell !important;
+}
+.text-center {
+ text-align: center !important;
+}
+.text-end {
+ text-align: right !important;
+}
+.truncate {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Responsive Design */
+@media (max-width: 1024px) {
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .settings-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 768px) {
+ .mobile-menu-toggle {
+ display: flex;
+ }
+
+ .sidebar {
+ transform: translateX(-100%);
+ }
+
+ .sidebar.active {
+ transform: translateX(0);
+ }
+
+ body.sidebar-open {
+ overflow: hidden;
+ }
+
+ body.sidebar-open::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 999;
+ }
+
+ .main-content {
+ margin-left: 0;
+ }
+
+ .top-bar {
+ padding: 0 var(--space-lg);
+ }
+
+ .page-title {
+ font-size: 1.5rem;
+ }
+
+ .content-section {
+ padding: var(--space-lg);
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .overview-cards-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .overview-mini-card {
+ padding: var(--space-md) var(--space-lg);
+ }
+
+ .overview-mini-value {
+ font-size: 1.5rem;
+ }
+
+ .overview-mini-icon {
+ width: 40px;
+ height: 40px;
+ }
+
+ .overview-mini-icon i {
+ font-size: 1.25rem;
+ }
+
+ .theme-options {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+@media (max-width: 480px) {
+ .top-bar {
+ padding: 0 var(--space-md);
+ }
+
+ .page-title {
+ font-size: 1.25rem;
+ }
+
+ .content-section {
+ padding: var(--space-md);
+ }
+
+ .stat-card {
+ padding: var(--space-lg);
+ }
+
+ .stat-value {
+ font-size: 2rem;
+ }
+
+ .card-header,
+ .card-body {
+ padding: var(--space-lg);
+ }
+
+ .theme-options {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* Animations */
+.fa-spin-pulse {
+ animation: spin 2s linear infinite;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+/* Time indicators */
+.time-remaining {
+ font-family: "Courier New", monospace;
+ font-weight: 600;
+ display: inline-block;
+ margin-right: var(--space-sm);
+}
+
+.upload-speed {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+ padding: 0.25rem 0.75rem;
+ background: var(--accent-transparent);
+ color: var(--accent-color);
+ border-radius: 999px;
+}
+
+/* Filter Buttons */
+.filter-buttons {
+ display: flex;
+ gap: var(--space-sm);
+ align-items: center;
+}
+
+.filter-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: all var(--transition);
+}
+
+.filter-btn:hover {
+ background: var(--accent-transparent);
+ color: var(--accent-color);
+ border-color: var(--accent-color);
+}
+
+.filter-btn.active {
+ background: var(--accent-color);
+ color: var(--bg-primary);
+ border-color: var(--accent-color);
+ font-weight: 600;
+}
+
+.filter-btn i {
+ font-size: 0.85rem;
+}
+
+/* Error indicators in tables */
+.table-danger {
+ background-color: rgba(239, 68, 68, 0.1);
+}
+
+.table td.has-error {
+ cursor: help;
+ text-decoration: underline;
+ text-decoration-style: dotted;
+ text-decoration-color: #ef4444;
+}
+
+.toggle-time-format {
+ cursor: pointer;
+ color: var(--text-tertiary);
+ margin-left: var(--space-sm);
+ transition: color var(--transition);
+}
+
+.toggle-time-format:hover {
+ color: var(--accent-color);
+}
+
+/* Table specific */
+.table-danger {
+ background: rgba(239, 68, 68, 0.1) !important;
+ border-left: 3px solid var(--danger);
+}
diff --git a/apps/docker-uploader/root/var/www/html/index.html b/apps/docker-uploader/root/var/www/html/index.html
index 96ba3dfeec..dc7ebab446 100755
--- a/apps/docker-uploader/root/var/www/html/index.html
+++ b/apps/docker-uploader/root/var/www/html/index.html
@@ -1,126 +1,1484 @@
-
-
-
-
+
+
+
+
Uploader Dashboard
-
-
-
-
-
-
-