package qbittorrent import ( "context" "errors" "fmt" "io" "log/slog" "os" "path" "path/filepath" "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" "github.com/royalcat/ctxio" ) type Daemon struct { proc *os.Process qb qbittorrent.Client client *cacheClient dataDir string 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 _, err = qb.Application().Version(ctx) 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 } return &Daemon{ qb: qb, proc: proc, dataDir: conf.DataFolder, 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 (d *Daemon) torrentPath(ih infohash.T) (string, error) { return filepath.Abs(path.Join(d.dataDir, ih.HexString())) } func (fs *Daemon) TorrentFS(ctx context.Context, file vfs.File) (vfs.Filesystem, error) { 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 := fs.torrentPath(ih) 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) } return newTorrentFS(ctx, fs.client, file.Name(), ih.HexString(), torrentPath) } func (d *Daemon) syncTorrentState(ctx context.Context, file vfs.File, ih metainfo.Hash, torrentPath string) error { log := d.log.With(slog.String("file", file.Name()), slog.String("infohash", ih.HexString())) existing, err := d.qb.Torrent().GetTorrents(ctx, &qbittorrent.TorrentOption{ Hashes: []string{ih.HexString()}, }) if err != nil { return fmt.Errorf("error to check torrent existence: %w", err) } log = log.With(slog.String("torrentPath", torrentPath)) if len(existing) == 0 { _, 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 { return err } for { _, err := d.qb.Torrent().GetProperties(ctx, ih.HexString()) if err == nil { break } 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())) if err != nil { d.log.Error(ctx, "error adding torrent", rlog.Error(err)) return err } return nil } else if len(existing) == 1 { // info := existing[0] props, err := d.qb.Torrent().GetProperties(ctx, ih.HexString()) if err != nil { return err } 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 } return fmt.Errorf("multiple torrents with the same infohash") } // 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 }