23 Commits

Author SHA1 Message Date
6e016b802b ye 2025-06-01 19:44:24 +02:00
72b85ec281 Add template 2025-05-21 09:44:20 +02:00
b2e3268ad1 Stop tracking config.toml 2025-05-21 09:42:35 +02:00
1af43b111c enclose MPD url 2025-05-21 09:40:45 +02:00
03312a0079 Fix accidentally registering same handler twice 2024-12-31 01:50:58 +01:00
a91163f845 config 2024-12-30 16:57:05 +01:00
7d28d1cea8 Merge pull request 'speedLimiter' (#10) from speedLimiter into main
Reviewed-on: #10
2024-12-30 16:47:45 +01:00
3fda737af2 Merge branch 'main' into speedLimiter 2024-12-30 16:47:21 +01:00
8cf3d4dda8 Show limit 2024-12-30 16:46:30 +01:00
2e18921a27 Show limit 2024-12-30 16:45:56 +01:00
f1efb1d67c impl speed limit 2024-12-30 16:32:12 +01:00
457ede5b62 Speed 2024-12-30 16:20:48 +01:00
7eb724d01f Speed 2024-12-30 16:16:51 +01:00
189bbb0874 Speed 2024-12-30 16:16:37 +01:00
68da5f9658 Speed 2024-12-30 16:16:21 +01:00
83cd0b722b style 2024-12-30 16:04:37 +01:00
ca176e1a76 Update README.md 2024-10-07 13:02:40 +02:00
54656f2630 Update README.md 2024-10-07 13:02:17 +02:00
f38b0c69d9 Merge pull request 'Poller' (#9) from Poller into main
Reviewed-on: #9
2024-10-07 12:59:18 +02:00
b1ba08933a Console should also beable to be controlled by env var 2024-10-07 12:48:37 +02:00
a049610291 Implement polling, update readme 2024-10-07 12:46:38 +02:00
c46538a55f Change the config paths according to new layout 2024-10-07 12:46:26 +02:00
fe6b7c78f6 Add options for polling, path validation, env variables 2024-10-07 12:45:49 +02:00
9 changed files with 400 additions and 41 deletions

View File

@ -7,17 +7,51 @@ drmdtool is a utility for processing .drmd files using N_m3u8DL-RE.
Create a `config.toml` file in the same directory as the drmdtool executable: Create a `config.toml` file in the same directory as the drmdtool executable:
```toml ```toml
[General]
BaseDir = "/path/to/save/downloads" BaseDir = "/path/to/save/downloads"
Format = "mkv" Format = "mkv"
TempBaseDir = "/tmp/nre" TempBaseDir = "/tmp/nre"
EnableConsole = true EnableConsole = true
WatchedFolder = "/path/to/watched/folder"
[N_m3u8DL-RE] [WatchFolder]
Path = "/path/to/watched/folder"
PollingInterval = 10
UsePolling = true
UseInotify = false
[N_m3u8DLRE]
Path = "/path/to/N_m3u8DL-RE" Path = "/path/to/N_m3u8DL-RE"
``` ```
Adjust the paths and format as needed. (mkv, mp4) ### Configuration Options
- **General**
- `BaseDir`: Directory where downloaded files will be saved.
- `Format`: Output format for the downloaded files (e.g., `mkv`, `mp4`).
- `TempBaseDir`: Temporary directory for intermediate files.
- `EnableConsole`: Boolean to enable or disable console output.
- **WatchFolder**
- `Path`: Directory to watch for new `.drmd` files.
- `PollingInterval`: Interval in seconds for polling the watch folder.
- `UsePolling`: Boolean to enable or disable folder polling.
- `UseInotify`: Boolean to enable or disable inotify for file watching.
- **N_m3u8DLRE**
- `Path`: Path to the N_m3u8DL-RE executable.
### Environment Variable Overrides
You can override the configuration options using environment variables. The following environment variables are supported:
- `BASE_DIR`: Overrides `General.BaseDir`
- `FORMAT`: Overrides `General.Format`
- `TEMP_BASE_DIR`: Overrides `General.TempBaseDir`
- `ENABLE_CONSOLE`: Overrides `General.EnableConsole` (set to `true` or `false`)
- `WATCHED_FOLDER`: Overrides `WatchFolder.Path`
- `USE_POLLING`: Overrides `WatchFolder.UsePolling` (set to `true` or `false`)
- `USE_INOTIFY`: Overrides `WatchFolder.UseInotify` (set to `true` or `false`)
- `POLLING_INTERVAL`: Overrides `WatchFolder.PollingInterval`
## Web UI Usage ## Web UI Usage
@ -30,7 +64,6 @@ Adjust the paths and format as needed. (mkv, mp4)
3. Use the interface to upload .drmd files and monitor download progress 3. Use the interface to upload .drmd files and monitor download progress
## CLI Usage ## CLI Usage
To process a file directly from the command line: To process a file directly from the command line:
@ -41,7 +74,6 @@ To process a file directly from the command line:
This will download the file and save it in the base directory specified in the config. This will download the file and save it in the base directory specified in the config.
# Previews # Previews
## Index Page ## Index Page

14
config.template.toml Normal file
View File

@ -0,0 +1,14 @@
[General]
BaseDir = "/mnt/media"
Format = "mkv"
TempBaseDir = "/tmp/nre"
EnableConsole = true
[WatchFolder]
Path = "/mnt/watched"
PollingInterval = 10
UsePolling = false
UseInotify = false
[N_m3u8DLRE]
Path = "nre"

View File

@ -1,8 +0,0 @@
BaseDir = "/mnt/media"
Format = "mkv"
TempBaseDir = "/tmp/nre"
EnableConsole = true
WatchedFolder = "/mnt/watched"
[N_m3u8DLRE]
Path = "nre"

View File

@ -4,19 +4,28 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"strconv"
"strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
type Config struct { type Config struct {
General struct {
BaseDir string BaseDir string
Format string Format string
TempBaseDir string TempBaseDir string
EnableConsole bool
}
WatchFolder struct {
Path string
UsePolling bool
UseInotify bool
PollingInterval int
}
N_m3u8DLRE struct { N_m3u8DLRE struct {
Path string Path string
} }
EnableConsole bool
WatchedFolder string
} }
var config Config var config Config
@ -24,25 +33,112 @@ var config Config
func loadConfig() { func loadConfig() {
configFile, err := os.Open("config.toml") configFile, err := os.Open("config.toml")
if err != nil { if err != nil {
fmt.Println("Error opening config file:", err) logger.LogError("Config", fmt.Sprintf("Error opening config file: %v", err))
return os.Exit(1)
} }
defer configFile.Close() defer configFile.Close()
byteValue, _ := io.ReadAll(configFile) byteValue, _ := io.ReadAll(configFile)
if _, err := toml.Decode(string(byteValue), &config); err != nil { if _, err := toml.Decode(string(byteValue), &config); err != nil {
fmt.Println("Error decoding config file:", err) logger.LogError("Config", fmt.Sprintf("Error decoding config file: %v", err))
return os.Exit(1)
} }
if config.N_m3u8DLRE.Path == "" { overrideConfigWithEnv()
fmt.Println("Error: N_m3u8DL-RE path is not specified in the config file")
return if err := validatePaths(); err != nil {
logger.LogError("Config", fmt.Sprintf("Configuration error: %v", err))
os.Exit(1)
} }
if config.WatchedFolder == "" { if config.WatchFolder.PollingInterval <= 0 {
fmt.Println("Error: Watched folder is not specified in the config file") config.WatchFolder.PollingInterval = 10
return }
logConfig()
}
func overrideConfigWithEnv() {
if envBaseDir := os.Getenv("BASE_DIR"); envBaseDir != "" {
config.General.BaseDir = envBaseDir
}
if envFormat := os.Getenv("FORMAT"); envFormat != "" {
config.General.Format = envFormat
}
if envTempBaseDir := os.Getenv("TEMP_BASE_DIR"); envTempBaseDir != "" {
config.General.TempBaseDir = envTempBaseDir
}
if envEnableConsole := os.Getenv("ENABLE_CONSOLE"); envEnableConsole != "" {
config.General.EnableConsole = strings.ToLower(envEnableConsole) == "true"
}
if envWatchedFolder := os.Getenv("WATCHED_FOLDER"); envWatchedFolder != "" {
config.WatchFolder.Path = envWatchedFolder
}
if envUsePolling := os.Getenv("USE_POLLING"); envUsePolling != "" {
config.WatchFolder.UsePolling = strings.ToLower(envUsePolling) == "true"
}
if envUseInotify := os.Getenv("USE_INOTIFY"); envUseInotify != "" {
config.WatchFolder.UseInotify = strings.ToLower(envUseInotify) == "true"
}
if envPollingInterval := os.Getenv("POLLING_INTERVAL"); envPollingInterval != "" {
if interval, err := strconv.Atoi(envPollingInterval); err == nil {
config.WatchFolder.PollingInterval = interval
}
} }
} }
func validatePaths() error {
paths := []struct {
name string
path string
}{
{"BaseDir", config.General.BaseDir},
}
for _, p := range paths {
if p.path == "" {
return fmt.Errorf("%s is not specified", p.name)
}
if _, err := os.Stat(p.path); os.IsNotExist(err) {
return fmt.Errorf("%s does not exist: %s", p.name, p.path)
} else if err != nil {
return fmt.Errorf("error accessing %s: %v", p.name, err)
}
}
if config.WatchFolder.UsePolling || config.WatchFolder.UseInotify {
if config.WatchFolder.Path == "" {
return fmt.Errorf("WatchedFolder is not specified")
}
if _, err := os.Stat(config.WatchFolder.Path); os.IsNotExist(err) {
return fmt.Errorf("WatchedFolder does not exist: %s", config.WatchFolder.Path)
} else if err != nil {
return fmt.Errorf("error accessing WatchedFolder: %v", err)
}
}
return nil
}
func logConfig() {
configInfo := fmt.Sprintf(`
Configuration Loaded:
General:
BaseDir: %s
Format: %s
TempBaseDir: %s
EnableConsole: %t
WatchFolder:
Path: %s
UsePolling: %t
UseInotify: %t
PollingInterval: %d
N_m3u8DLRE:
Path: %s
`, config.General.BaseDir, config.General.Format, config.General.TempBaseDir, config.General.EnableConsole,
config.WatchFolder.Path, config.WatchFolder.UsePolling, config.WatchFolder.UseInotify, config.WatchFolder.PollingInterval,
config.N_m3u8DLRE.Path)
logger.LogInfo("Config", configInfo)
}

View File

@ -23,7 +23,7 @@ func removeBOM(input []byte) []byte {
func downloadFile(drmdFilename string, item Item, jobInfo *JobInfo) error { func downloadFile(drmdFilename string, item Item, jobInfo *JobInfo) error {
logger.LogInfo("Download File", fmt.Sprintf("Starting download for: %s", item.Filename)) logger.LogInfo("Download File", fmt.Sprintf("Starting download for: %s", item.Filename))
tempDir := filepath.Join(config.TempBaseDir, sanitizeFilename(item.Filename)) tempDir := filepath.Join(config.General.TempBaseDir, sanitizeFilename(item.Filename))
err := os.MkdirAll(tempDir, 0755) err := os.MkdirAll(tempDir, 0755)
if err != nil { if err != nil {
logger.LogError("Download File", fmt.Sprintf("Error creating temporary directory: %v", err)) logger.LogError("Download File", fmt.Sprintf("Error creating temporary directory: %v", err))
@ -132,7 +132,7 @@ func downloadFile(drmdFilename string, item Item, jobInfo *JobInfo) error {
for { for {
if outputBuffer.Len() > 0 { if outputBuffer.Len() > 0 {
message := outputBuffer.Bytes() message := outputBuffer.Bytes()
if config.EnableConsole { if config.General.EnableConsole {
broadcast(drmdFilename, message) broadcast(drmdFilename, message)
} }
outputBuffer.Reset() outputBuffer.Reset()
@ -168,7 +168,7 @@ func getDownloadCommand(item Item, mpdPath string, tempDir string) string {
metadata := parseMetadata(item.Metadata) metadata := parseMetadata(item.Metadata)
keys := getKeys(item.Keys) keys := getKeys(item.Keys)
command := fmt.Sprintf("%s %s", config.N_m3u8DLRE.Path, mpdPath) command := fmt.Sprintf("%s '%s'", config.N_m3u8DLRE.Path, mpdPath)
for _, key := range keys { for _, key := range keys {
if key != "" { if key != "" {
@ -183,9 +183,9 @@ func getDownloadCommand(item Item, mpdPath string, tempDir string) string {
filename := fmt.Sprintf("\"%s\"", sanitizedFilename) filename := fmt.Sprintf("\"%s\"", sanitizedFilename)
command += fmt.Sprintf(" --save-name %s", filename) command += fmt.Sprintf(" --save-name %s", filename)
command += fmt.Sprintf(" --mux-after-done format=%s", config.Format) command += fmt.Sprintf(" --mux-after-done format=%s", config.General.Format)
saveDir := config.BaseDir saveDir := config.General.BaseDir
if metadata.Type == "serie" { if metadata.Type == "serie" {
saveDir = filepath.Join(saveDir, "Series", metadata.Title, metadata.Season) saveDir = filepath.Join(saveDir, "Series", metadata.Title, metadata.Season)
} else { } else {
@ -195,6 +195,10 @@ func getDownloadCommand(item Item, mpdPath string, tempDir string) string {
command += fmt.Sprintf(" --tmp-dir \"%s\"", tempDir) command += fmt.Sprintf(" --tmp-dir \"%s\"", tempDir)
if globalSpeedLimit != "" {
command += fmt.Sprintf(" -R %s", globalSpeedLimit)
}
fmt.Println(command) fmt.Println(command)
return command return command

View File

@ -48,7 +48,11 @@ func handleRoot(w http.ResponseWriter, r *http.Request) {
CurrentFile string CurrentFile string
Paused bool Paused bool
} }
}{jobsInfo}) GlobalSpeedLimit string
}{
Jobs: jobsInfo,
GlobalSpeedLimit: globalSpeedLimit,
})
if err != nil { if err != nil {
logger.LogError("Handle Root", fmt.Sprintf("Error executing template: %v", err)) logger.LogError("Handle Root", fmt.Sprintf("Error executing template: %v", err))
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -386,8 +390,8 @@ var clients = make(map[string]map[*websocket.Conn]bool)
var mu sync.Mutex var mu sync.Mutex
func handleWebSocket(w http.ResponseWriter, r *http.Request) { func handleWebSocket(w http.ResponseWriter, r *http.Request) {
fmt.Println(config.EnableConsole) fmt.Println(config.General.EnableConsole)
if !config.EnableConsole { if !config.General.EnableConsole {
http.Error(w, "Console output is disabled", http.StatusForbidden) http.Error(w, "Console output is disabled", http.StatusForbidden)
return return
} }
@ -428,7 +432,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
} }
func broadcast(filename string, message []byte) { func broadcast(filename string, message []byte) {
if !config.EnableConsole { if !config.General.EnableConsole {
return return
} }
@ -443,3 +447,32 @@ func broadcast(filename string, message []byte) {
} }
} }
} }
func handleSetSpeedLimit(w http.ResponseWriter, r *http.Request) {
logger.LogInfo("Set Speed Limit", "Received request to set speed limit")
if r.Method != http.MethodPost {
logger.LogError("Set Speed Limit", "Invalid method")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var requestData struct {
SpeedLimit string `json:"speedLimit"`
}
if err := json.NewDecoder(r.Body).Decode(&requestData); err != nil {
logger.LogError("Set Speed Limit", "Invalid request body")
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if requestData.SpeedLimit == "unlimited" {
globalSpeedLimit = ""
} else {
globalSpeedLimit = requestData.SpeedLimit
}
logger.LogInfo("Set Speed Limit", fmt.Sprintf("Global speed limit set to: %s", globalSpeedLimit))
w.WriteHeader(http.StatusOK)
}

View File

@ -44,6 +44,8 @@ var templates *template.Template
//go:embed templates //go:embed templates
var templateFS embed.FS var templateFS embed.FS
var globalSpeedLimit string
func init() { func init() {
if err := os.MkdirAll(uploadDir, 0755); err != nil { if err := os.MkdirAll(uploadDir, 0755); err != nil {
fmt.Printf("Error creating upload directory: %v\n", err) fmt.Printf("Error creating upload directory: %v\n", err)
@ -83,8 +85,9 @@ func startWebServer() {
http.HandleFunc("/resume", handleResume) http.HandleFunc("/resume", handleResume)
http.HandleFunc("/clear-completed", handleClearCompleted) http.HandleFunc("/clear-completed", handleClearCompleted)
http.HandleFunc("/ws", handleWebSocket) http.HandleFunc("/ws", handleWebSocket)
http.HandleFunc("/set-speed-limit", handleSetSpeedLimit)
fmt.Println("Starting web server on http://0.0.0.0:8080") logger.LogInfo("Main", "Starting web server on http://0.0.0.0:8080")
http.ListenAndServe(":8080", nil) http.ListenAndServe(":8080", nil)
} }

View File

@ -28,7 +28,6 @@
input[type="file"], input[type="submit"] { input[type="file"], input[type="submit"] {
background-color: #2d2d2d; background-color: #2d2d2d;
color: #d4d4d4; color: #d4d4d4;
border: 1px solid #444;
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
margin-bottom: 10px; margin-bottom: 10px;
@ -77,6 +76,11 @@
.paused { .paused {
color: #ffa500; color: #ffa500;
} }
.speed-limit {
font-size: 1em;
color: #a0a0a0;
margin-top: 10px;
}
@media (max-width: 600px) { @media (max-width: 600px) {
body { body {
padding: 10px; padding: 10px;
@ -107,6 +111,94 @@
#clear-completed:hover { #clear-completed:hover {
background-color: #d32f2f; background-color: #d32f2f;
} }
/* New CSS for speed limit form */
.settings-section {
margin-top: 30px;
}
.speed-limit-form {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 20px;
}
.speed-limit-form .form-group {
display: flex;
align-items: center;
gap: 10px;
}
.speed-limit-form input[type="number"],
.speed-limit-form select,
.speed-limit-form button {
background-color: #2d2d2d;
color: #d4d4d4;
border: 1px solid #444;
padding: 8px 12px;
border-radius: 4px;
}
.speed-limit-form button {
cursor: pointer;
background-color: #4CAF50;
color: white;
}
.speed-limit-form button:hover {
background-color: #45a049;
}
.speed-limit-container {
display: flex;
align-items: center;
margin-bottom: 20px;
background-color: #2d2d2d;
padding: 8px 12px;
border-radius: 4px;
}
.speed-limit-container .form-group {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.speed-limit-container input[type="number"] {
background-color: #2d2d2d;
color: #d4d4d4;
border: 1px solid #444;
padding: 8px 12px;
border-radius: 4px;
height: 40px;
box-sizing: border-box;
flex-grow: 1;
}
.speed-limit-container select,
.speed-limit-container button {
background-color: #2d2d2d;
color: #d4d4d4;
border: 1px solid #444;
padding: 8px 12px;
border-radius: 4px;
height: 40px;
box-sizing: border-box;
}
.speed-limit-container button {
cursor: pointer;
background-color: #4CAF50;
color: white;
}
.speed-limit-container button:hover {
background-color: #45a049;
}
.speed-limit-container .speed-limit {
color: #d4d4d4;
margin-left: auto;
display: flex;
align-items: center;
}
.speed-limit-container .speed-limit span {
margin-left: 5px;
}
.current-speed-limit {
color: #d4d4d4;
margin-top: 10px;
}
</style> </style>
</head> </head>
<body> <body>
@ -135,6 +227,23 @@
{{end}} {{end}}
</ul> </ul>
<button id="clear-completed" onclick="clearCompleted()">Clear Completed Jobs</button> <button id="clear-completed" onclick="clearCompleted()">Clear Completed Jobs</button>
<div class="settings-section">
<h2>Settings</h2>
<div class="speed-limit-container">
<div class="form-group">
<label for="speedLimitValue">Speed Limit:</label>
<input type="number" id="speedLimitValue" name="speedLimitValue" min="0" step="0.01" required>
<select id="speedLimitUnit" name="speedLimitUnit">
<option value="GBps">GBps</option>
<option value="MBps" selected>MBps</option>
<option value="KBps">KBps</option>
</select>
<button type="button" onclick="updateSpeedLimit(event)">Set Limit</button>
</div>
</div>
</div>
<script> <script>
function clearCompleted() { function clearCompleted() {
fetch('/clear-completed', { method: 'POST' }) fetch('/clear-completed', { method: 'POST' })
@ -147,6 +256,54 @@
} }
}); });
} }
function updateSpeedLimit(event) {
event.preventDefault();
const speedLimitValue = document.getElementById('speedLimitValue').value;
const speedLimitUnit = document.getElementById('speedLimitUnit').value;
const speedLimit = speedLimitValue === "0" ? "unlimited" : speedLimitValue + speedLimitUnit;
if (!validateSpeedLimit(speedLimitValue)) {
alert('Please enter a valid speed limit.');
return;
}
fetch('/set-speed-limit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ speedLimit }),
}).then(response => {
if (response.ok) {
alert('Speed limit updated successfully');
document.getElementById('currentSpeedLimit').textContent = speedLimit;
} else {
alert('Failed to update speed limit');
}
});
}
function validateSpeedLimit(value) {
const number = parseFloat(value);
return !isNaN(number) && number >= 0;
}
document.addEventListener('DOMContentLoaded', function() {
const currentSpeedLimit = "{{if .GlobalSpeedLimit}}{{.GlobalSpeedLimit}}{{else}}0{{end}}";
const speedLimitValueInput = document.getElementById('speedLimitValue');
const speedLimitUnitSelect = document.getElementById('speedLimitUnit');
const match = currentSpeedLimit.match(/(\d+(\.\d+)?)([A-Za-z]+)/);
if (match) {
speedLimitValueInput.value = match[1];
speedLimitUnitSelect.value = match[3];
} else {
speedLimitValueInput.value = "0";
speedLimitUnitSelect.value = "MBps";
}
});
</script> </script>
</body> </body>
</html> </html>

View File

@ -13,6 +13,16 @@ import (
) )
func watchFolder() { func watchFolder() {
if config.WatchFolder.UsePolling {
go pollFolder()
}
if config.WatchFolder.UseInotify {
go inotifyWatch()
}
}
func inotifyWatch() {
watcher, err := fsnotify.NewWatcher() watcher, err := fsnotify.NewWatcher()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -43,13 +53,31 @@ func watchFolder() {
} }
}() }()
err = watcher.Add(config.WatchedFolder) err = watcher.Add(config.WatchFolder.Path)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
<-done <-done
} }
func pollFolder() {
ticker := time.NewTicker(time.Duration(config.WatchFolder.PollingInterval) * time.Second)
defer ticker.Stop()
for range ticker.C {
files, err := filepath.Glob(filepath.Join(config.WatchFolder.Path, "*.drmd"))
if err != nil {
log.Println("Error polling folder:", err)
continue
}
for _, file := range files {
fmt.Println("New .drmd detected via polling:", file)
go processWatchedFile(file)
}
}
}
func processWatchedFile(filePath string) { func processWatchedFile(filePath string) {
for { for {
initialSize, err := getFileSize(filePath) initialSize, err := getFileSize(filePath)