package qbittorrent import ( "context" "errors" "fmt" "io" "io/fs" "log/slog" "os" "path" "strings" "sync" "time" "git.kmsign.ru/royalcat/tstor/pkg/qbittorrent" "git.kmsign.ru/royalcat/tstor/pkg/rlog" "git.kmsign.ru/royalcat/tstor/src/vfs" "github.com/iceber/iouring-go" ) type FS struct { mu sync.Mutex client *cacheClient name string hash string dataDir string // directory where torrent files are stored modTime time.Time ur *iouring.IOURing entries map[string]fileEntry log *rlog.Logger vfs.FilesystemPrototype } type fileEntry struct { fs.FileInfo Content *qbittorrent.TorrentContent } var _ vfs.Filesystem = (*FS)(nil) func newTorrentFS(ctx context.Context, client *cacheClient, name string, hash string, modTime time.Time, dataDir string) (*FS, error) { ctx, span := trace.Start(ctx, "newTorrentFS") defer span.End() cnts, err := client.listContent(ctx, hash) if err != nil { return nil, fmt.Errorf("failed to list content for hash %s: %w", hash, err) } entries := make(map[string]fileEntry, len(cnts)) for _, cnt := range cnts { if cnt.Priority == qbittorrent.PriorityDoNotDownload { continue } entries[vfs.AbsPath(cnt.Name)] = fileEntry{ Content: cnt, FileInfo: vfs.NewFileInfo(cnt.Name, cnt.Size, modTime), } } return &FS{ client: client, name: name, hash: hash, modTime: modTime, dataDir: dataDir, entries: entries, log: rlog.Component("qbittorrent", "fs"), FilesystemPrototype: vfs.FilesystemPrototype(name), }, nil } // Open implements vfs.Filesystem. func (f *FS) Open(ctx context.Context, name string) (vfs.File, error) { if name == vfs.Separator { return vfs.NewDirFile(name), nil } if entry, ok := f.entries[name]; ok { return openFile(ctx, f.ur, f.client, f.dataDir, f.hash, entry.Content) } for p := range f.entries { if strings.HasPrefix(p, name) { return vfs.NewDirFile(name), nil } } return nil, vfs.ErrNotExist } // ReadDir implements vfs.Filesystem. func (f *FS) ReadDir(ctx context.Context, name string) ([]fs.DirEntry, error) { infos := make(map[string]fs.FileInfo, len(f.entries)) for k, v := range f.entries { infos[k] = v.FileInfo } return vfs.ListDirFromInfo(infos, name) } // Stat implements vfs.Filesystem. func (f *FS) Stat(ctx context.Context, name string) (fs.FileInfo, error) { name = vfs.AbsPath(path.Clean(name)) if vfs.IsRoot(name) { return vfs.NewDirInfo(f.name, f.modTime), nil } if entry, ok := f.entries[name]; ok { return entry.FileInfo, nil } for p := range f.entries { if strings.HasPrefix(p, name) { return vfs.NewDirInfo(name, f.modTime), nil } } return nil, vfs.ErrNotExist } // Unlink implements vfs.Filesystem. func (f *FS) Unlink(ctx context.Context, filename string) error { filename = vfs.AbsPath(path.Clean(filename)) // we cannot delete a torrent itself, cause it will be added on next source scan and all delited files will be restored if entry, ok := f.entries[filename]; ok { return f.removeFile(ctx, f.hash, entry.Content) } for p, entry := range f.entries { if strings.HasPrefix(p, filename) { return f.removeFile(ctx, f.hash, entry.Content) } } return vfs.ErrNotExist } func (f *FS) Rename(ctx context.Context, oldpath string, newpath string) error { oldpath = vfs.AbsPath(path.Clean(oldpath)) newpath = vfs.AbsPath(path.Clean(newpath)) if _, ok := f.entries[oldpath]; ok { err := f.client.qb.Torrent().RenameFile(ctx, f.hash, vfs.RelPath(oldpath), vfs.RelPath(newpath)) if err != nil { return fmt.Errorf("failed to rename file %s to %s: %w", oldpath, newpath, err) } f.mu.Lock() defer f.mu.Unlock() f.entries[newpath] = f.entries[oldpath] return nil } return vfs.ErrNotExist } func (f *FS) removeFile(ctx context.Context, hash string, content *qbittorrent.TorrentContent) error { log := f.log.With(slog.String("hash", hash), slog.String("file", content.Name)) f.mu.Lock() defer f.mu.Unlock() fpath := vfs.AbsPath(content.Name) if _, ok := f.entries[fpath]; !ok { return fmt.Errorf("file %s is does not found", fpath) } delete(f.entries, fpath) err := f.client.qb.Torrent().SetFilePriority(ctx, f.hash, content.Index, qbittorrent.PriorityDoNotDownload) if err != nil { return fmt.Errorf("failed to set priority for torrent %s for file %s: %w", hash, content.Name, err) } err = os.Remove(path.Join(f.dataDir, vfs.RelPath(content.Name))) if err != nil && !errors.Is(err, fs.ErrNotExist) { log.Warn(ctx, "failed to remove file", rlog.Error(err)) return fmt.Errorf("failed to remove file %s: %w", content.Name, err) } return nil } func openFile(ctx context.Context, ur *iouring.IOURing, client *cacheClient, torrentDir string, hash string, content *qbittorrent.TorrentContent) (*File, error) { props, err := client.getProperties(ctx, hash) if err != nil { return nil, err } // FIXME error when file not started downloading file, err := os.OpenFile(path.Join(torrentDir, content.Name), os.O_RDONLY, 0) if err != nil { return nil, err } return &File{ client: client, hash: hash, torrentDir: torrentDir, filePath: content.Name, contentIndex: content.Index, pieceSize: props.PieceSize, fileSize: content.Size, file: file, offset: 0, }, nil } type File struct { client *cacheClient hash string torrentModTime time.Time torrentDir string filePath string // path inside a torrent directory contentIndex int pieceSize int fileSize int64 mu sync.Mutex file *os.File offset int64 } var _ vfs.File = (*File)(nil) // Info implements vfs.File. func (f *File) Info() (fs.FileInfo, error) { return vfs.NewFileInfo(path.Base(f.filePath), f.fileSize, f.torrentModTime), nil } // IsDir implements vfs.File. func (f *File) IsDir() bool { return false } // Seek implements vfs.File. func (f *File) Seek(offset int64, whence int) (int64, error) { switch whence { case io.SeekStart: f.offset = offset case io.SeekCurrent: f.offset += offset case io.SeekEnd: f.offset = f.fileSize + offset } return f.offset, nil } // Name implements vfs.File. func (f *File) Name() string { return path.Base(f.filePath) } func (f *File) canExpectSoon(ctx context.Context) (bool, error) { info, err := f.client.getInfo(ctx, f.hash) if err != nil { return false, err } if info == nil { return false, nil } return info.Completed == info.Size || info.State == qbittorrent.TorrentStateCheckingUP || info.State == qbittorrent.TorrentStateDownloading || info.State == qbittorrent.TorrentStateForcedDL, nil } func (f *File) isRangeComplete(ctx context.Context, offset int64, size int) (bool, error) { startPieceIndex := int(offset / int64(f.pieceSize)) pieceCount := (size + f.pieceSize - 1) / f.pieceSize // rouding up for i := range pieceCount { ok, err := f.client.isPieceComplete(ctx, f.hash, startPieceIndex+i) if err != nil { return false, err } if !ok { return false, nil } } return true, nil } func (f *File) waitRangeAvailable(ctx context.Context, offset int64, size int) error { complete, err := f.isRangeComplete(ctx, offset, size) if err != nil { return err } if complete { return nil } canExpectSoon, err := f.canExpectSoon(ctx) if err != nil { return err } if !canExpectSoon { return fmt.Errorf("torrent is not downloading") } const checkingInterval = 1 * time.Second ticker := time.NewTicker(checkingInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: complete, err := f.isRangeComplete(ctx, offset, size) if err != nil { return err } if complete { return nil } } } } // Read implements vfs.File. func (f *File) Read(ctx context.Context, p []byte) (int, error) { f.mu.Lock() defer f.mu.Unlock() if err := f.waitRangeAvailable(ctx, f.offset, len(p)); err != nil { return 0, err } n, err := f.file.ReadAt(p, f.offset) f.offset += int64(n) return n, err } // ReadAt implements vfs.File. func (f *File) ReadAt(ctx context.Context, p []byte, off int64) (int, error) { if err := f.waitRangeAvailable(ctx, f.offset, len(p)); err != nil { return 0, err } return f.file.ReadAt(p, off) } // Size implements vfs.File. func (f *File) Size() int64 { return f.fileSize } // Type implements vfs.File. func (f *File) Type() fs.FileMode { return fs.ModeDir } // Close implements vfs.File. func (f *File) Close(ctx context.Context) error { return f.file.Close() } // type fileInfo struct { // name string // size int64 // modTime time.Time // } // var _ fs.FileInfo = (*fileInfo)(nil) // // IsDir implements fs.FileInfo. // func (f *fileInfo) IsDir() bool { // return false // } // // ModTime implements fs.FileInfo. // func (f *fileInfo) ModTime() time.Time { // return f.modTime // } // // Mode implements fs.FileInfo. // func (f *fileInfo) Mode() fs.FileMode { // return vfs.ModeFileRO // } // // Name implements fs.FileInfo. // func (f *fileInfo) Name() string { // return f.name // } // // Size implements fs.FileInfo. // func (f *fileInfo) Size() int64 { // return f.size // } // // Sys implements fs.FileInfo. // func (f *fileInfo) Sys() any { // return nil // }