Compare commits

..

7 Commits

22 changed files with 908 additions and 4 deletions

View File

@@ -26,12 +26,16 @@
- Multi-user support
- Auto organize files based on storage rules
- Watch specified chats and auto-save messages, with filters
- Transfer files between different storage backends
- Integrate with yt-dlp to download and save media from 1000+ websites
- Aria2 integration to download files from URLs/magnets and save to storages
- Write JS parser plugins to save files from almost any website
- Storage backends:
- Alist
- S3
- WebDAV
- Local filesystem
- Rclone (via command line)
- Telegram (re-upload to specified chats)
## 📦 Quick Start

View File

@@ -24,12 +24,16 @@
- 多用户使用
- 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤
- 在不同存储端之间转存文件
- 集成 yt-dlp, 从所支持的网站下载并转存媒体文件
- 集成 Aria2, 支持直链/磁力下载和转存
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist
- S3
- WebDAV
- 本地磁盘
- Rclone
- Telegram (重传回指定聊天)
## 快速开始

View File

@@ -16,6 +16,7 @@ var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageCo
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
storenum.S3: createStorageConfig(&S3StorageConfig{}),
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
storenum.Rclone: createStorageConfig(&RcloneStorageConfig{}),
}
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {

33
config/storage/rclone.go Normal file
View File

@@ -0,0 +1,33 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type RcloneStorageConfig struct {
BaseConfig
// The name of the remote as defined in rclone config
Remote string `toml:"remote" mapstructure:"remote" json:"remote"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
// The path to the rclone config file, if not using the default
ConfigPath string `toml:"config_path" mapstructure:"config_path" json:"config_path"`
// Additional flags to pass to rclone commands
Flags []string `toml:"flags" mapstructure:"flags" json:"flags"`
}
func (r *RcloneStorageConfig) Validate() error {
if r.Remote == "" {
return fmt.Errorf("remote is required for rclone storage")
}
return nil
}
func (r *RcloneStorageConfig) GetType() storenum.StorageType {
return storenum.Rclone
}
func (r *RcloneStorageConfig) GetName() string {
return r.Name
}

View File

@@ -45,9 +45,17 @@ func (t *Task) Execute(ctx context.Context) error {
fetchedTotalBytes.Add(resp.ContentLength)
file.Size = resp.ContentLength
if name := resp.Header.Get("Content-Disposition"); name != "" {
// Set file name
filename := parseFilename(name)
file.Name = filename
if filename != "" {
file.Name = filename
}
}
// extract filename from URL if Content-Disposition is empty or invalid
if file.Name == "" {
file.Name = parseFilenameFromURL(file.URL)
}
if file.Name == "" {
return fmt.Errorf("failed to determine filename for %s: Content-Disposition header is empty and URL does not contain a valid filename", file.URL)
}
return nil

View File

@@ -76,6 +76,9 @@ func (t *Task) StorageName() string {
// StoragePath implements TaskInfo.
func (t *Task) StoragePath() string {
if len(t.files) == 1 {
return t.StorPath + "/" + t.files[0].Name
}
return t.StorPath
}

View File

@@ -144,6 +144,41 @@ func tryDecodeGBK(s string) string {
return ""
}
// parseFilenameFromURL extracts filename from URL path
// This is used as a fallback when Content-Disposition is not available
func parseFilenameFromURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
// Get the path part and extract the last segment
path := parsed.Path
if path == "" {
return ""
}
// URL decode the path first
decodedPath, err := url.PathUnescape(path)
if err != nil {
decodedPath = path
}
// Get the last segment of the path
lastSlash := strings.LastIndex(decodedPath, "/")
if lastSlash == -1 {
return decodedPath
}
filename := decodedPath[lastSlash+1:]
// Remove query string if somehow still present
if idx := strings.Index(filename, "?"); idx != -1 {
filename = filename[:idx]
}
return filename
}
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
func parseFilenameFallback(cd string) string {
// Look for filename= (case-insensitive)

View File

@@ -0,0 +1,73 @@
package directlinks
import (
"testing"
)
func TestParseFilenameFromURL(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "simple filename",
url: "https://example.com/files/document.pdf",
expected: "document.pdf",
},
{
name: "filename with encoded characters",
url: "https://example.com/files/%E6%B5%8B%E8%AF%95.zip",
expected: "测试.zip",
},
{
name: "filename with query string in URL",
url: "https://example.com/files/image.png?token=abc123",
expected: "image.png",
},
{
name: "nested path",
url: "https://example.com/a/b/c/file.txt",
expected: "file.txt",
},
{
name: "URL with port",
url: "https://example.com:8080/downloads/archive.tar.gz",
expected: "archive.tar.gz",
},
{
name: "empty path",
url: "https://example.com",
expected: "",
},
{
name: "root path only",
url: "https://example.com/",
expected: "",
},
{
name: "filename with spaces encoded",
url: "https://example.com/my%20file%20name.pdf",
expected: "my file name.pdf",
},
{
name: "complex encoded filename",
url: "https://example.com/downloads/%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.docx",
expected: "中文文件.docx",
},
{
name: "invalid URL",
url: "://invalid-url",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseFilenameFromURL(tt.url)
if result != tt.expected {
t.Errorf("parseFilenameFromURL(%q) = %q, want %q", tt.url, result, tt.expected)
}
})
}
}

View File

@@ -20,6 +20,9 @@ Save Any Bot is a tool that allows you to save files from Telegram to various st
- Multi-user
- Automatic organization based on storage rules
- Watch specific chats and automatically save messages, with filters
- Transfer files between different storage backends
- Integrate with yt-dlp to download and save media from 1000+ websites
- Aria2 integration to download files from URLs/magnets and save to storages
- Write JS parser plugins to save files from almost any website
- Supports multiple storage backends:
- Alist

View File

@@ -92,6 +92,27 @@ enable = false
session = "data/usersession.db"
```
### Aria2 Configuration
Aria2 is a powerful download manager that supports HTTP/HTTPS, FTP, BitTorrent, and other protocols. When enabled, the bot can use the `/aria2dl` command to download files via Aria2.
- `enable`: Whether to enable Aria2 support, default is `false`
- `url`: Aria2 RPC address, typically `http://localhost:6800/jsonrpc`
- `secret`: Aria2 RPC secret, if you configured `rpc-secret` in Aria2, you need to fill it in here
- `remove_after_transfer`: Whether to remove local files downloaded by Aria2 after transfer, default is `true`
{{< hint info >}}
Aria2 needs to be installed and running separately. You can refer to the [Aria2 official documentation](https://aria2.github.io/) to learn how to install and configure Aria2.
{{< /hint >}}
```toml
[aria2]
enable = true
url = "http://localhost:6800/jsonrpc"
secret = "your-rpc-secret"
remove_after_transfer = true
```
### Storage Endpoints List
The storage endpoints list is used to define the storage locations supported by the Bot. Each storage endpoint needs to specify a name, type, and related configuration, using the double bracket syntax `[[storages]]`.

View File

@@ -80,4 +80,60 @@ chat_id = "123456789" # Telegram chat ID, the bot will send files to this chat
force_file = false # Force sending as file, default is false
skip_large = false # Skip large files, default is false. If enabled, files exceeding Telegram's limit will not be uploaded.
spilt_size_mb = 2000 # Split size in MB, default is 2000 MB (2 GB). Files larger than this will be split into multiple parts (zip format). Ignored when skip_large is true.
```
## Rclone
`type=rclone`
Supports multiple cloud storage services through the [rclone](https://rclone.org/) command-line tool. You need to install rclone and configure remote storage first.
```toml
# Remote name configured in rclone, can be any remote defined in rclone.conf
remote = "mydrive"
# Base path in the remote storage, all files will be stored under this path
base_path = "/telegram"
# Path to rclone config file, optional, leave empty to use default path (~/.config/rclone/rclone.conf)
config_path = ""
# Additional flags to pass to rclone commands, optional
flags = ["--transfers", "4", "--checkers", "8"]
```
### Configuring rclone Remote
First, you need to configure an rclone remote. Run `rclone config` for interactive configuration, or directly edit the `rclone.conf` file.
rclone supports many cloud storage services, including but not limited to:
- Google Drive
- Dropbox
- OneDrive
- Amazon S3 and compatible services
- SFTP
- FTP
- For more services, please refer to the [rclone official documentation](https://rclone.org/overview/)
### Usage Examples
After configuring Google Drive, you can configure the storage like this:
```toml
[[storages]]
name = "GoogleDrive"
type = "rclone"
enable = true
remote = "gdrive"
base_path = "/SaveAnyBot"
```
If using a custom rclone config file:
```toml
[[storages]]
name = "MyRemote"
type = "rclone"
enable = true
remote = "myremote"
base_path = "/backup"
config_path = "/path/to/rclone.conf"
flags = ["--progress"]
```

View File

@@ -112,6 +112,142 @@ Regex-match the message text. For example:
This will watch the chat with ID `12345678`, and only save messages whose text contains `hello`.
## Direct Download Links
Use the `/dl` command to directly download one or more HTTP/HTTPS files to storage.
```bash
/dl <url1> [url2] [url3] ...
```
Examples:
```bash
/dl https://example.com/file.zip
/dl https://example.com/file1.zip https://example.com/file2.zip
```
The bot will validate the link format and then ask you to select the target storage location.
## Aria2 Download
{{< hint warning >}}
This feature requires enabling Aria2 in the configuration file and configuring the RPC connection.
{{< /hint >}}
Use the `/aria2dl` command to download files via the Aria2 download manager, supporting HTTP/HTTPS, FTP, BitTorrent, and other protocols.
```bash
/aria2dl <uri1> [uri2] [uri3] ...
```
Examples:
```bash
# Download HTTP link
/aria2dl https://example.com/file.zip
# Download magnet link
/aria2dl magnet:?xt=urn:btih:...
# Download torrent file (need to upload .torrent file first)
/aria2dl https://example.com/file.torrent
```
Configure Aria2:
Add to `config.toml`:
```toml
[aria2]
enable = true
url = "http://localhost:6800/jsonrpc"
secret = "your-rpc-secret" # If rpc-secret is configured
remove_after_transfer = true # Remove local files after transfer
```
## yt-dlp Video Download
{{< hint warning >}}
This feature requires the yt-dlp command-line tool installed on your system.
{{< /hint >}}
Use the `/ytdlp` command to download videos and audio from supported video websites, including YouTube, Bilibili, Twitter, and 1000+ other sites.
```bash
/ytdlp <url1> [url2] [flags...]
```
Examples:
```bash
# Basic download
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ
# Download multiple videos
/ytdlp https://www.youtube.com/watch?v=video1 https://www.youtube.com/watch?v=video2
# Use custom parameters
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ -f best
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ --extract-audio --audio-format mp3
```
Common parameters:
- `-f <format>`: Specify download format (e.g., `best`, `worst`, `bestvideo+bestaudio`)
- `--extract-audio`: Extract audio
- `--audio-format <format>`: Audio format (e.g., `mp3`, `m4a`, `wav`)
- `--write-sub`: Download subtitles
- `--write-thumbnail`: Download thumbnail
For more parameters, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp#usage-and-options).
## Storage Transfer
Use the `/transfer` command to transfer files directly between different storages without going through Telegram.
```bash
/transfer <source_storage>:/<source_path> [filter]
```
Parameters:
- `source_storage`: Source storage name
- `source_path`: Source path
- `filter`: Optional regex filter to transfer only matching files
Examples:
```bash
# Transfer entire directory
/transfer local1:/downloads
# Transfer files from specified path
/transfer alist1:/media/photos
# Transfer only mp4 files
/transfer webdav1:/videos ".*\.mp4$"
# Transfer image files
/transfer local1:/pictures "(?i)\.(jpg|png|gif)$"
```
The bot will:
1. List all files in the source path
2. Apply the filter (if provided)
3. Display file count and total size
4. Ask you to select the target storage
5. Ask you to select the target directory (if configured for that storage)
6. Start the transfer task
Notes:
- Source storage must support listing and reading
- Target storage must support writing
- Real-time progress is displayed during transfer
- Transfer tasks can be cancelled
## Save Files Outside Telegram
Besides files on Telegram, the bot can also save files from other websites via JavaScript plugins or built-in parsers.

View File

@@ -20,12 +20,16 @@ title: 介绍
- 多用户使用
- 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤
- 在不同存储端之间转存文件
- 集成 yt-dlp, 从所支持的网站下载并转存媒体文件
- 集成 Aria2, 支持直链/磁力下载和转存
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist
- S3
- WebDAV
- 本地磁盘
- Rclone (通过命令行调用)
- Telegram (重传回指定聊天)
## [贡献者](https://github.com/krau/SaveAny-Bot/graphs/contributors)

View File

@@ -90,6 +90,27 @@ enable = false
session = "data/usersession.db"
```
### Aria2 配置
Aria2 是一个强大的下载管理器,支持 HTTP/HTTPS、FTP、BitTorrent 等多种协议。启用后Bot 可以使用 `/aria2dl` 命令通过 Aria2 下载文件。
- `enable`: 是否启用 Aria2 支持,默认为 `false`
- `url`: Aria2 RPC 地址,通常为 `http://localhost:6800/jsonrpc`
- `secret`: Aria2 RPC 密钥,如果你在 Aria2 中配置了 `rpc-secret`,需要在此填写
- `remove_after_transfer`: 转存完成后是否删除 Aria2 下载的本地文件,默认为 `true`
{{< hint info >}}
Aria2 需要单独安装和运行。你可以参考 [Aria2 官方文档](https://aria2.github.io/) 了解如何安装和配置 Aria2。
{{< /hint >}}
```toml
[aria2]
enable = true
url = "http://localhost:6800/jsonrpc"
secret = "your-rpc-secret"
remove_after_transfer = true
```
### 存储端列表
存储端列表用于定义 Bot 支持的存储位置, 每个存储端需要指定名称、类型和相关配置, 使用双中括号语法 `[[storages]]` 定义.

View File

@@ -86,4 +86,60 @@ skip_large = false
# 超过该大小的文件将被分割成多个部分上传.(使用 zip 格式)
# 当 skip_large 启用时, 该选项无效.
spilt_size_mb = 2000
```
## Rclone
`type=rclone`
通过 [rclone](https://rclone.org/) 命令行工具支持多种云存储服务. 需要先安装 rclone 并配置好远程存储.
```toml
# rclone 配置的远程名称, 可以是任何在 rclone.conf 中配置的远程
remote = "mydrive"
# 在远程存储中的基础路径, 所有文件将存储在此路径下
base_path = "/telegram"
# rclone 配置文件的路径, 可选, 留空使用默认路径 (~/.config/rclone/rclone.conf)
config_path = ""
# 传递给 rclone 命令的额外参数, 可选
flags = ["--transfers", "4", "--checkers", "8"]
```
### 配置 rclone 远程
首先需要配置 rclone 远程, 运行 `rclone config` 命令进行交互式配置, 或直接编辑 `rclone.conf` 文件.
rclone 支持多种云存储服务, 包括但不限于:
- Google Drive
- Dropbox
- OneDrive
- Amazon S3 及兼容服务
- SFTP
- FTP
- 更多服务请参考 [rclone 官方文档](https://rclone.org/overview/)
### 使用示例
配置 Google Drive 后, 可以这样配置存储:
```toml
[[storages]]
name = "GoogleDrive"
type = "rclone"
enable = true
remote = "gdrive"
base_path = "/SaveAnyBot"
```
如果使用自定义的 rclone 配置文件:
```toml
[[storages]]
name = "MyRemote"
type = "rclone"
enable = true
remote = "myremote"
base_path = "/backup"
config_path = "/path/to/rclone.conf"
flags = ["--progress"]
```

View File

@@ -112,6 +112,142 @@ IS-ALBUM true MyWebdav NEW-FOR-ALBUM
这将会监听 ID 为 12345678 的聊天, 并且只保存消息文本中包含 "hello" 的消息.
## 直接下载链接
使用 `/dl` 命令可以直接下载一个或多个 HTTP/HTTPS 链接的文件到存储中.
```bash
/dl <url1> [url2] [url3] ...
```
示例:
```bash
/dl https://example.com/file.zip
/dl https://example.com/file1.zip https://example.com/file2.zip
```
Bot 会验证链接格式, 然后让你选择目标存储位置.
## Aria2 下载
{{< hint warning >}}
该功能需要在配置文件中启用 Aria2 并配置 RPC 连接.
{{< /hint >}}
使用 `/aria2dl` 命令可以通过 Aria2 下载管理器下载文件, 支持 HTTP/HTTPS、FTP、BitTorrent 等多种协议.
```bash
/aria2dl <uri1> [uri2] [uri3] ...
```
示例:
```bash
# 下载 HTTP 链接
/aria2dl https://example.com/file.zip
# 下载磁力链接
/aria2dl magnet:?xt=urn:btih:...
# 下载种子文件 (需要先上传 .torrent 文件)
/aria2dl https://example.com/file.torrent
```
配置 Aria2:
`config.toml` 中添加:
```toml
[aria2]
enable = true
url = "http://localhost:6800/jsonrpc"
secret = "your-rpc-secret" # 如果配置了 rpc-secret
remove_after_transfer = true # 转存完成后删除本地文件
```
## yt-dlp 视频下载
{{< hint warning >}}
该功能需要在系统中安装 yt-dlp 命令行工具.
{{< /hint >}}
使用 `/ytdlp` 命令可以下载支持的视频网站的视频和音频, 支持 YouTube、Bilibili、Twitter 等 1000+ 个网站.
```bash
/ytdlp <url1> [url2] [flags...]
```
示例:
```bash
# 基本下载
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ
# 下载多个视频
/ytdlp https://www.youtube.com/watch?v=video1 https://www.youtube.com/watch?v=video2
# 使用自定义参数
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ -f best
/ytdlp https://www.youtube.com/watch?v=dQw4w9WgXcQ --extract-audio --audio-format mp3
```
常用参数:
- `-f <format>`: 指定下载格式 (如 `best`, `worst`, `bestvideo+bestaudio`)
- `--extract-audio`: 提取音频
- `--audio-format <format>`: 音频格式 (如 `mp3`, `m4a`, `wav`)
- `--write-sub`: 下载字幕
- `--write-thumbnail`: 下载缩略图
更多参数请参考 [yt-dlp 文档](https://github.com/yt-dlp/yt-dlp#usage-and-options).
## 存储间传输
使用 `/transfer` 命令可以在不同存储之间直接传输文件, 无需经过 Telegram.
```bash
/transfer <source_storage>:/<source_path> [filter]
```
参数说明:
- `source_storage`: 源存储名称
- `source_path`: 源路径
- `filter`: 可选的正则表达式过滤器, 只传输匹配的文件
示例:
```bash
# 传输整个目录
/transfer local1:/downloads
# 传输指定路径的文件
/transfer alist1:/media/photos
# 只传输 mp4 文件
/transfer webdav1:/videos ".*\.mp4$"
# 传输图片文件
/transfer local1:/pictures "(?i)\.(jpg|png|gif)$"
```
Bot 会:
1. 列出源路径下的所有文件
2. 应用过滤器 (如果提供)
3. 显示文件数量和总大小
4. 让你选择目标存储
5. 让你选择目标目录 (如果该存储配置了目录)
6. 开始传输任务
注意:
- 源存储必须支持列举和读取功能
- 目标存储必须支持写入功能
- 传输过程显示实时进度
- 支持取消正在进行的传输任务
## 转存 Telegram 之外的文件
除了 Telegram 上的文件, Bot 还可通过 JavaScript 插件或内置解析器来支持转存其他网站的文件.

View File

@@ -4,6 +4,6 @@ package storage
// StorageType
/* ENUM(
local, webdav, alist, minio, telegram, s3
local, webdav, alist, minio, telegram, s3, rclone
) */
type StorageType string

View File

@@ -24,6 +24,8 @@ const (
Telegram StorageType = "telegram"
// S3 is a StorageType of type s3.
S3 StorageType = "s3"
// Rclone is a StorageType of type rclone.
Rclone StorageType = "rclone"
)
var ErrInvalidStorageType = fmt.Errorf("not a valid StorageType, try [%s]", strings.Join(_StorageTypeNames, ", "))
@@ -35,6 +37,7 @@ var _StorageTypeNames = []string{
string(Minio),
string(Telegram),
string(S3),
string(Rclone),
}
// StorageTypeNames returns a list of possible string values of StorageType.
@@ -53,6 +56,7 @@ func StorageTypeValues() []StorageType {
Minio,
Telegram,
S3,
Rclone,
}
}
@@ -75,6 +79,7 @@ var _StorageTypeValue = map[string]StorageType{
"minio": Minio,
"telegram": Telegram,
"s3": S3,
"rclone": Rclone,
}
// ParseStorageType attempts to convert a string to a StorageType.

View File

@@ -104,7 +104,7 @@ func (a *Alist) Name() string {
func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string) error {
a.logger.Infof("Saving file to %s", storagePath)
storagePath = a.JoinStoragePath(storagePath)
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath

14
storage/rclone/errs.go Normal file
View File

@@ -0,0 +1,14 @@
package rclone
import "errors"
var (
ErrRcloneNotFound = errors.New("rclone: rclone command not found in PATH")
ErrRemoteNotFound = errors.New("rclone: remote not found")
ErrFailedToSaveFile = errors.New("rclone: failed to save file")
ErrFailedToListFiles = errors.New("rclone: failed to list files")
ErrFailedToOpenFile = errors.New("rclone: failed to open file")
ErrFailedToCheckFile = errors.New("rclone: failed to check file exists")
ErrFailedToCreateDir = errors.New("rclone: failed to create directory")
ErrCommandFailed = errors.New("rclone: command execution failed")
)

289
storage/rclone/rclone.go Normal file
View File

@@ -0,0 +1,289 @@
package rclone
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"path"
"strings"
"time"
"github.com/charmbracelet/log"
config "github.com/krau/SaveAny-Bot/config/storage"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/rs/xid"
)
type Rclone struct {
config config.RcloneStorageConfig
logger *log.Logger
}
func (r *Rclone) Init(ctx context.Context, cfg config.StorageConfig) error {
rcloneConfig, ok := cfg.(*config.RcloneStorageConfig)
if !ok {
return fmt.Errorf("failed to cast rclone config")
}
if err := rcloneConfig.Validate(); err != nil {
return err
}
r.config = *rcloneConfig
r.logger = log.FromContext(ctx).WithPrefix(fmt.Sprintf("rclone[%s]", r.config.Name))
// 检查 rclone 是否安装
if _, err := exec.LookPath("rclone"); err != nil {
return ErrRcloneNotFound
}
args := r.buildBaseArgs()
args = append(args, "listremotes")
cmd := exec.CommandContext(ctx, "rclone", args...)
output, err := cmd.Output()
if err != nil {
r.logger.Errorf("Failed to list remotes: %v", err)
return fmt.Errorf("failed to verify rclone: %w", err)
}
remoteName := strings.TrimSuffix(r.config.Remote, ":")
if !strings.HasSuffix(r.config.Remote, ":") {
remoteName = r.config.Remote
}
found := false
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
line = strings.TrimSuffix(line, ":")
if line == remoteName {
found = true
break
}
}
if !found {
r.logger.Errorf("Remote %s not found in rclone config", r.config.Remote)
return ErrRemoteNotFound
}
r.logger.Infof("Initialized rclone storage with remote: %s", r.config.Remote)
return nil
}
func (r *Rclone) Type() storenum.StorageType {
return storenum.Rclone
}
func (r *Rclone) Name() string {
return r.config.Name
}
func (r *Rclone) buildBaseArgs() []string {
var args []string
if r.config.ConfigPath != "" {
args = append(args, "--config", r.config.ConfigPath)
}
args = append(args, r.config.Flags...)
return args
}
func (r *Rclone) getRemotePath(storagePath string) string {
remote := r.config.Remote
if !strings.HasSuffix(remote, ":") {
remote += ":"
}
basePath := strings.TrimPrefix(r.config.BasePath, "/")
fullPath := path.Join(basePath, storagePath)
return remote + fullPath
}
func (r *Rclone) Save(ctx context.Context, reader io.Reader, storagePath string) error {
r.logger.Infof("Saving file to %s", storagePath)
ext := path.Ext(storagePath)
base := strings.TrimSuffix(storagePath, ext)
candidate := storagePath
for i := 1; r.Exists(ctx, candidate); i++ {
candidate = fmt.Sprintf("%s_%d%s", base, i, ext)
if i > 100 {
r.logger.Errorf("Too many attempts to find a unique filename for %s", storagePath)
candidate = fmt.Sprintf("%s_%s%s", base, xid.New().String(), ext)
break
}
}
remotePath := r.getRemotePath(candidate)
r.logger.Debugf("Remote path: %s", remotePath)
// Use rclone rcat to read from stdin and upload
args := r.buildBaseArgs()
args = append(args, "rcat", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
cmd.Stdin = reader
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
r.logger.Errorf("Failed to save file: %v, stderr: %s", err, stderr.String())
return fmt.Errorf("%w: %s", ErrFailedToSaveFile, stderr.String())
}
r.logger.Infof("Successfully saved file to %s", candidate)
return nil
}
func (r *Rclone) Exists(ctx context.Context, storagePath string) bool {
remotePath := r.getRemotePath(storagePath)
args := r.buildBaseArgs()
args = append(args, "lsf", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
err := cmd.Run()
return err == nil
}
// lsjsonItem represents a single entry in the output of `rclone lsjson`
type lsjsonItem struct {
Path string `json:"Path"`
Name string `json:"Name"`
Size int64 `json:"Size"`
MimeType string `json:"MimeType"`
ModTime string `json:"ModTime"`
IsDir bool `json:"IsDir"`
}
// ListFiles implements storage.StorageListable
func (r *Rclone) ListFiles(ctx context.Context, dirPath string) ([]storagetypes.FileInfo, error) {
r.logger.Infof("Listing files in %s", dirPath)
remotePath := r.getRemotePath(dirPath)
args := r.buildBaseArgs()
args = append(args, "lsjson", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
r.logger.Errorf("Failed to list files: %v, stderr: %s", err, stderr.String())
return nil, fmt.Errorf("%w: %s", ErrFailedToListFiles, stderr.String())
}
var items []lsjsonItem
if err := json.Unmarshal(stdout.Bytes(), &items); err != nil {
r.logger.Errorf("Failed to parse lsjson output: %v", err)
return nil, fmt.Errorf("failed to parse lsjson output: %w", err)
}
files := make([]storagetypes.FileInfo, 0, len(items))
for _, item := range items {
var modTime time.Time
if item.ModTime != "" {
parsedTime, err := time.Parse(time.RFC3339Nano, item.ModTime)
if err != nil {
r.logger.Warnf("Failed to parse mod time %q for %s: %v", item.ModTime, item.Name, err)
} else {
modTime = parsedTime
}
}
files = append(files, storagetypes.FileInfo{
Name: item.Name,
Path: path.Join(dirPath, item.Name),
Size: item.Size,
IsDir: item.IsDir,
ModTime: modTime,
})
}
r.logger.Debugf("Found %d files/directories in %s", len(files), dirPath)
return files, nil
}
// OpenFile implements storage.StorageReadable
func (r *Rclone) OpenFile(ctx context.Context, filePath string) (io.ReadCloser, int64, error) {
r.logger.Infof("Opening file %s", filePath)
remotePath := r.getRemotePath(filePath)
size, err := r.getFileSize(ctx, remotePath)
if err != nil {
r.logger.Errorf("Failed to get file size: %v", err)
return nil, 0, fmt.Errorf("%w: %v", ErrFailedToOpenFile, err)
}
args := r.buildBaseArgs()
args = append(args, "cat", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, 0, fmt.Errorf("failed to create stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, 0, fmt.Errorf("failed to start rclone cat: %w", err)
}
reader := &rcloneCatReader{
reader: stdout,
cmd: cmd,
logger: r.logger,
}
r.logger.Debugf("Opened file %s (size: %d bytes)", filePath, size)
return reader, size, nil
}
func (r *Rclone) getFileSize(ctx context.Context, remotePath string) (int64, error) {
args := r.buildBaseArgs()
args = append(args, "lsjson", remotePath)
cmd := exec.CommandContext(ctx, "rclone", args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return 0, err
}
var items []lsjsonItem
if err := json.Unmarshal(stdout.Bytes(), &items); err != nil {
return 0, err
}
if len(items) > 0 {
return items[0].Size, nil
}
return 0, nil
}
type rcloneCatReader struct {
reader io.ReadCloser
cmd *exec.Cmd
logger *log.Logger
}
func (r *rcloneCatReader) Read(p []byte) (n int, err error) {
return r.reader.Read(p)
}
func (r *rcloneCatReader) Close() error {
if err := r.reader.Close(); err != nil {
r.logger.Warnf("Failed to close reader: %v", err)
}
if err := r.cmd.Wait(); err != nil {
r.logger.Warnf("rclone cat process exited with error: %v", err)
}
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/krau/SaveAny-Bot/storage/alist"
"github.com/krau/SaveAny-Bot/storage/local"
"github.com/krau/SaveAny-Bot/storage/minio"
"github.com/krau/SaveAny-Bot/storage/rclone"
"github.com/krau/SaveAny-Bot/storage/s3"
"github.com/krau/SaveAny-Bot/storage/telegram"
"github.com/krau/SaveAny-Bot/storage/webdav"
@@ -53,6 +54,7 @@ var storageConstructors = map[storenum.StorageType]StorageConstructor{
storenum.Minio: func() Storage { return new(minio.Minio) },
storenum.S3: func() Storage { return new(s3.S3) },
storenum.Telegram: func() Storage { return new(telegram.Telegram) },
storenum.Rclone: func() Storage { return new(rclone.Rclone) },
}
// NewStorage creates a new storage instance based on the provided config and initializes it