package qbittorrent import ( "context" "errors" "fmt" "io" "log/slog" "os" "path" "path/filepath" "sync" "time" "git.kmsign.ru/royalcat/tstor/pkg/qbittorrent" "git.kmsign.ru/royalcat/tstor/pkg/rlog" "git.kmsign.ru/royalcat/tstor/src/config" "git.kmsign.ru/royalcat/tstor/src/logwrap" "git.kmsign.ru/royalcat/tstor/src/vfs" "github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/types/infohash" infohash_v2 "github.com/anacrolix/torrent/types/infohash-v2" mapset "github.com/deckarep/golang-set/v2" "github.com/iceber/iouring-go" "github.com/royalcat/ctxio" "go.opentelemetry.io/otel" ) var trace = otel.Tracer("git.kmsign.ru/royalcat/tstor/daemons/qbittorrent") type Daemon struct { proc *os.Process qb qbittorrent.Client client *cacheClient sourceFilesMu sync.Mutex sourceFiles map[string]string // [sourcePath]infohash registeredTorrents mapset.Set[string] // infohash list dataDir string ur *iouring.IOURing log *rlog.Logger } const defaultConf = ` [LegalNotice] Accepted=true [Preferences] WebUI\LocalHostAuth=false WebUI\Password_PBKDF2="@ByteArray(qef5I4wZBkDG+PP6/5mQwA==:LoTmorQM/QM5RHI4+dOiu6xfAz9xak6fhR4ZGpRtJF3JNCGG081Yrtva4G71kXz//ODUuWQKTLlrZPuIDvzqUQ==)" ` func NewDaemon(conf config.QBittorrent) (*Daemon, error) { ctx := context.Background() log := rlog.Component("qbittorrent") binPath := conf.MetadataFolder + "/qbittorrent-nox" err := downloadLatestQbitRelease(ctx, binPath) if err != nil { return nil, err } daemonLog := log.WithComponent("process") outLog := logwrap.NewSlogWriter(ctx, slog.LevelInfo, daemonLog.Slog()) errLog := logwrap.NewSlogWriter(ctx, slog.LevelError, daemonLog.Slog()) _, err = os.Stat(conf.MetadataFolder + "/profile/qBittorrent/config/qBittorrent.conf") if errors.Is(err, os.ErrNotExist) { err = os.MkdirAll(conf.MetadataFolder+"/profile/qBittorrent/config", 0744) if err != nil { return nil, err } err = os.WriteFile(conf.MetadataFolder+"/profile/qBittorrent/config/qBittorrent.conf", []byte(defaultConf), 0644) if err != nil { return nil, err } } err = os.MkdirAll(conf.DataFolder, 0744) if err != nil { return nil, err } const port = 25436 proc, err := runQBittorrent(binPath, conf.MetadataFolder+"/profile", port, outLog, errLog) if err != nil { return nil, err } time.Sleep(time.Second) qb, err := qbittorrent.NewClient(ctx, &qbittorrent.Config{ Address: fmt.Sprintf("http://localhost:%d", port), }) if err != nil { return nil, err } for { // wait for qbittorrent to start ver, err := qb.Application().Version(ctx) log.Info(ctx, "qbittorrent started", slog.String("version", ver)) if err == nil { break } log.Warn(ctx, "waiting for qbittorrent to start", rlog.Error(err)) time.Sleep(time.Second) } dataDir, err := filepath.Abs(conf.DataFolder) if err != nil { return nil, err } err = qb.Application().SetPreferences(ctx, &qbittorrent.Preferences{ SavePath: dataDir, }) if err != nil { return nil, err } ur, err := iouring.New(8, iouring.WithAsync()) if err != nil { return nil, err } return &Daemon{ qb: qb, proc: proc, dataDir: conf.DataFolder, ur: ur, sourceFiles: make(map[string]string), registeredTorrents: mapset.NewSet[string](), client: wrapClient(qb), log: rlog.Component("qbittorrent"), }, nil } func (d *Daemon) Close(ctx context.Context) error { err := d.proc.Signal(os.Interrupt) if err != nil { return err } _, err = d.proc.Wait() if err != nil { return err } return nil } func torrentDataPath(dataDir string, ih string) (string, error) { return filepath.Abs(path.Join(dataDir, ih)) } func (fs *Daemon) GetTorrentFS(ctx context.Context, sourcePath string, file vfs.File) (vfs.Filesystem, error) { ctx, span := trace.Start(ctx, "GetTorrentFS") defer span.End() log := fs.log.With(slog.String("file", file.Name())) ih, err := readInfoHash(ctx, file) if err != nil { return nil, err } log = log.With(slog.String("infohash", ih.HexString())) torrentPath, err := torrentDataPath(fs.dataDir, ih.HexString()) if err != nil { return nil, fmt.Errorf("error getting torrent path: %w", err) } log = log.With(slog.String("torrentPath", torrentPath)) log.Debug(ctx, "creating fs for torrent") err = fs.syncTorrentState(ctx, file, ih, torrentPath) if err != nil { return nil, fmt.Errorf("error syncing torrent state: %w", err) } fs.sourceFilesMu.Lock() fs.sourceFiles[sourcePath] = ih.HexString() fs.sourceFilesMu.Unlock() return newTorrentFS(ctx, fs.ur, fs.client, file.Name(), ih.HexString(), torrentPath) } func (d *Daemon) syncTorrentState(ctx context.Context, file vfs.File, ih metainfo.Hash, torrentPath string) error { ctx, span := trace.Start(ctx, "syncTorrentState") defer span.End() log := d.log.With(slog.String("file", file.Name()), slog.String("infohash", ih.HexString())) info, err := d.client.getInfo(ctx, ih.HexString()) if err != nil { return err } log = log.With(slog.String("torrentPath", torrentPath)) if info == nil { _, err := file.Seek(0, io.SeekStart) if err != nil { return err } data, err := ctxio.ReadAll(ctx, file) if err != nil { return err } err = d.qb.Torrent().AddNewTorrent(ctx, &qbittorrent.TorrentAddOption{ Torrents: []*qbittorrent.TorrentAddFileMetadata{ { Data: data, }, }, SavePath: torrentPath, // SequentialDownload: "true", FirstLastPiecePrio: "true", }) if err != nil { d.log.Error(ctx, "error adding torrent", rlog.Error(err)) return err } var props *qbittorrent.TorrentProperties for { props, err = d.client.getProperties(ctx, ih.HexString()) if err == nil { break } if errors.Is(err, context.DeadlineExceeded) { return err } log.Error(ctx, "waiting for torrent to be added", rlog.Error(err)) time.Sleep(time.Millisecond * 15) } log.Info(ctx, "added torrent", slog.String("infohash", ih.HexString())) d.registeredTorrents.Add(props.Hash) return nil } else { // info := existing[0] props, err := d.client.getProperties(ctx, ih.HexString()) if err != nil { return err } d.registeredTorrents.Add(props.Hash) if props.SavePath != torrentPath { log.Info(ctx, "moving torrent to correct location", slog.String("oldPath", props.SavePath)) err = d.qb.Torrent().SetLocation(ctx, []string{ih.HexString()}, torrentPath) if err != nil { return err } } return nil } } // TODO caching func readInfoHash(ctx context.Context, file vfs.File) (infohash.T, error) { mi, err := metainfo.Load(ctxio.IoReader(ctx, file)) if err != nil { return infohash.T{}, err } info, err := mi.UnmarshalInfo() if err != nil { return infohash.T{}, err } if info.HasV2() { ih := infohash_v2.HashBytes(mi.InfoBytes) return *(&ih).ToShort(), nil } return infohash.HashBytes(mi.InfoBytes), nil }