Initial commit
Proof-of-concept implementation. Bugs will occur.
This commit is contained in:
53
internal/domains/filesystem/directories.go
Normal file
53
internal/domains/filesystem/directories.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (f *FS) prepareDirectories() error {
|
||||
if _, err := os.Stat(f.sourceDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("%w: %w (%w)", ErrFilesystem, ErrNoSource, err)
|
||||
}
|
||||
|
||||
f.app.Logger().WithField("path", f.sourceDir).Info("Got source directory")
|
||||
|
||||
// Clean destination directory
|
||||
if _, err := os.Stat(f.destinationDir); err == nil {
|
||||
f.app.Logger().WithField("path", f.destinationDir).Info(
|
||||
"Cleaning up the destination mountpoint",
|
||||
)
|
||||
|
||||
// Try to unmount the destination FS if that was mounted before.
|
||||
exec.Command("fusermount3", "-u", f.destinationDir).Run()
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Clean the destination
|
||||
err := os.RemoveAll(f.destinationDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w (%w)", ErrFilesystem, ErrFailedToCleanupDestination, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the structure for the virtual filesystem.
|
||||
for _, dir := range []string{f.destinationDir, f.cacheDir, f.metadataDir} {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
f.app.Logger().WithField("path", dir).Error("Operation on directory was unsuccessful")
|
||||
|
||||
return fmt.Errorf("%w: %w (%w)", ErrFilesystem, ErrFailedToCreateDestinationDirectory, err)
|
||||
}
|
||||
}
|
||||
|
||||
f.app.Logger().WithFields(logrus.Fields{
|
||||
"source directory": f.sourceDir,
|
||||
"virtual filesystem mount": f.destinationDir,
|
||||
"cache directory": f.cacheDir,
|
||||
"metadata directory": f.metadataDir,
|
||||
}).Debug("Filesystem directories prepared")
|
||||
|
||||
return nil
|
||||
}
|
||||
12
internal/domains/filesystem/errors.go
Normal file
12
internal/domains/filesystem/errors.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package filesystem
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrFilesystem = errors.New("filesystem")
|
||||
ErrConnectDependencies = errors.New("failed to connect dependencies")
|
||||
ErrFailedToPrepareDirectories = errors.New("failed to prepare directories")
|
||||
ErrNoSource = errors.New("source does not exist")
|
||||
ErrFailedToCleanupDestination = errors.New("failed to clean up destination directory")
|
||||
ErrFailedToCreateDestinationDirectory = errors.New("failed to create destination directory")
|
||||
)
|
||||
75
internal/domains/filesystem/file.go
Normal file
75
internal/domains/filesystem/file.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
file *os.File
|
||||
fileMutex sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.FileReader)((*File)(nil))
|
||||
_ = (fs.FileWriter)((*File)(nil))
|
||||
_ = (fs.FileFlusher)((*File)(nil))
|
||||
_ = (fs.FileReleaser)((*File)(nil))
|
||||
)
|
||||
|
||||
func (fi *File) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||
fi.fileMutex.Lock()
|
||||
defer fi.fileMutex.Unlock()
|
||||
|
||||
_, err := fi.file.Seek(off, io.SeekStart)
|
||||
if err != nil {
|
||||
return nil, syscall.EIO
|
||||
}
|
||||
|
||||
n, err := fi.file.Read(dest)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, syscall.EIO
|
||||
}
|
||||
|
||||
return fuse.ReadResultData(dest[:n]), 0
|
||||
}
|
||||
|
||||
func (fi *File) Write(ctx context.Context, data []byte, off int64) (written uint32, errno syscall.Errno) {
|
||||
fi.fileMutex.Lock()
|
||||
defer fi.fileMutex.Unlock()
|
||||
|
||||
n, err := fi.file.WriteAt(data, off)
|
||||
if err != nil {
|
||||
return 0, syscall.EIO
|
||||
}
|
||||
|
||||
return uint32(n), 0
|
||||
}
|
||||
|
||||
func (fi *File) Flush(ctx context.Context) syscall.Errno {
|
||||
fi.fileMutex.Lock()
|
||||
defer fi.fileMutex.Unlock()
|
||||
|
||||
if err := fi.file.Sync(); err != nil {
|
||||
return syscall.EIO
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (fi *File) Release(ctx context.Context) syscall.Errno {
|
||||
fi.fileMutex.Lock()
|
||||
defer fi.fileMutex.Unlock()
|
||||
|
||||
if err := fi.file.Close(); err != nil {
|
||||
return syscall.EIO
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
66
internal/domains/filesystem/filesystem.go
Normal file
66
internal/domains/filesystem/filesystem.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"source.hodakov.me/hdkv/faketunes/internal/application"
|
||||
"source.hodakov.me/hdkv/faketunes/internal/domains"
|
||||
)
|
||||
|
||||
var (
|
||||
_ domains.Filesystem = new(FS)
|
||||
_ domains.Domain = new(FS)
|
||||
)
|
||||
|
||||
type FS struct {
|
||||
app *application.App
|
||||
|
||||
cacher domains.Cacher
|
||||
|
||||
sourceDir string
|
||||
destinationDir string
|
||||
cacheDir string
|
||||
metadataDir string
|
||||
|
||||
inodeCounter uint64
|
||||
}
|
||||
|
||||
func New(app *application.App) *FS {
|
||||
return &FS{
|
||||
app: app,
|
||||
|
||||
sourceDir: app.Config().Paths.Source,
|
||||
destinationDir: app.Config().Paths.Destination + "/Music",
|
||||
cacheDir: app.Config().Paths.Destination + "/.cache",
|
||||
metadataDir: app.Config().Paths.Destination + "/.metadata",
|
||||
|
||||
inodeCounter: 1000, // Start counting inodes after the reserved ones
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FS) ConnectDependencies() error {
|
||||
cacher, ok := f.app.RetrieveDomain(domains.CacherName).(domains.Cacher)
|
||||
if !ok {
|
||||
return fmt.Errorf(
|
||||
"%w: %w (%s)", ErrFilesystem, ErrConnectDependencies,
|
||||
"cacher domain interface conversion failed",
|
||||
)
|
||||
}
|
||||
|
||||
f.cacher = cacher
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FS) Start() error {
|
||||
err := f.prepareDirectories()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w (%w)", ErrFilesystem, ErrFailedToPrepareDirectories, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
f.mount()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
7
internal/domains/filesystem/inode.go
Normal file
7
internal/domains/filesystem/inode.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package filesystem
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
func (f *FS) nextInode() uint64 {
|
||||
return atomic.AddUint64(&f.inodeCounter, 1)
|
||||
}
|
||||
52
internal/domains/filesystem/mount.go
Normal file
52
internal/domains/filesystem/mount.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (f *FS) mount() {
|
||||
rootDir := f.NewRootDirectory()
|
||||
|
||||
// Populate mount options
|
||||
opts := &fs.Options{
|
||||
MountOptions: fuse.MountOptions{
|
||||
Name: "faketunes",
|
||||
FsName: "faketunes",
|
||||
DisableXAttrs: false, // Enable xattr support for macOS
|
||||
Debug: false,
|
||||
// AllowOther: true,
|
||||
Options: []string{
|
||||
"default_permissions",
|
||||
"fsname=flac2alac",
|
||||
"nosuid",
|
||||
"nodev",
|
||||
"noexec",
|
||||
"ro",
|
||||
},
|
||||
},
|
||||
NullPermissions: false,
|
||||
Logger: log.New(os.Stdout, "FUSE: ", log.LstdFlags),
|
||||
}
|
||||
|
||||
// Redirect FUSE logs to logrus
|
||||
log.SetOutput(f.app.Logger().WithField("fuse debug logs", true).WriterLevel(logrus.DebugLevel))
|
||||
|
||||
// Do an actual mount
|
||||
server, err := fs.Mount(f.destinationDir, rootDir, opts)
|
||||
if err != nil {
|
||||
f.app.Logger().WithError(err).Fatal("Failed to start filesystem")
|
||||
}
|
||||
defer server.Unmount()
|
||||
|
||||
select {
|
||||
case <-f.app.Context().Done():
|
||||
return
|
||||
default:
|
||||
server.Wait()
|
||||
}
|
||||
}
|
||||
158
internal/domains/filesystem/music_app_metadata.go
Normal file
158
internal/domains/filesystem/music_app_metadata.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type MusicAppMetadataFile struct {
|
||||
fs.Inode
|
||||
|
||||
f *FS
|
||||
path string
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeGetattrer)((*MusicAppMetadataFile)(nil))
|
||||
_ = (fs.NodeOpener)((*MusicAppMetadataFile)(nil))
|
||||
_ = (fs.NodeCreater)((*MusicAppMetadataFile)(nil))
|
||||
_ = (fs.NodeWriter)((*MusicAppMetadataFile)(nil))
|
||||
_ = (fs.NodeSetattrer)((*MusicAppMetadataFile)(nil))
|
||||
_ = (fs.NodeUnlinker)((*MusicAppMetadataFile)(nil))
|
||||
)
|
||||
|
||||
func (m *MusicAppMetadataFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
info, err := os.Stat(m.path)
|
||||
if err != nil {
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = m.StableAttr().Ino
|
||||
out.Size = 0
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
out.Mode = fuse.S_IFREG | uint32(info.Mode())
|
||||
out.Nlink = 1
|
||||
out.Ino = m.StableAttr().Ino
|
||||
out.Size = uint64(info.Size())
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *MusicAppMetadataFile) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
|
||||
info, err := os.Stat(m.path)
|
||||
if err != nil {
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = m.StableAttr().Ino
|
||||
out.Size = 0
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
} else {
|
||||
out.Mode = fuse.S_IFREG | uint32(info.Mode())
|
||||
out.Nlink = 1
|
||||
out.Ino = m.StableAttr().Ino
|
||||
out.Size = uint64(info.Size())
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *MusicAppMetadataFile) Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
file, err := os.Create(m.path)
|
||||
if err != nil {
|
||||
return nil, nil, 0, syscall.EIO
|
||||
}
|
||||
|
||||
ch := m.NewInode(ctx, &MusicAppMetadataFile{path: m.path}, fs.StableAttr{
|
||||
Mode: fuse.S_IFREG,
|
||||
Ino: m.f.nextInode(),
|
||||
})
|
||||
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
out.Size = 0
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
|
||||
return ch, &File{file: file}, fuse.FOPEN_DIRECT_IO, 0
|
||||
}
|
||||
|
||||
func (m *MusicAppMetadataFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
if _, err := os.Stat(m.path); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(m.path, []byte{}, 0644); err != nil {
|
||||
return nil, 0, syscall.EIO
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(m.path, int(flags), 0644)
|
||||
if err != nil {
|
||||
return nil, 0, syscall.EIO
|
||||
}
|
||||
|
||||
return &File{file: file}, fuse.FOPEN_DIRECT_IO, 0
|
||||
}
|
||||
|
||||
func (m *MusicAppMetadataFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (written uint32, errno syscall.Errno) {
|
||||
handle, ok := fh.(*File)
|
||||
if !ok {
|
||||
return 0, syscall.EBADF
|
||||
}
|
||||
|
||||
n, err := handle.file.WriteAt(data, off)
|
||||
if err != nil {
|
||||
return 0, syscall.EIO
|
||||
}
|
||||
|
||||
return uint32(n), 0
|
||||
}
|
||||
|
||||
func (m *MusicAppMetadataFile) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||
if err := os.Remove(m.path); err != nil {
|
||||
return syscall.ENOENT
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (f *FS) isiTunesMetadata(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
|
||||
return strings.HasPrefix(name, ".") ||
|
||||
strings.Contains(name, "albumart") ||
|
||||
strings.Contains(name, "folder") ||
|
||||
strings.Contains(name, "itunes") ||
|
||||
strings.HasSuffix(name, ".itl") ||
|
||||
strings.HasSuffix(name, ".xml") ||
|
||||
strings.HasSuffix(name, ".db")
|
||||
}
|
||||
|
||||
func (f *FS) NewMusicAppMetadataFile(path string) *MusicAppMetadataFile {
|
||||
return &MusicAppMetadataFile{
|
||||
f: f,
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
287
internal/domains/filesystem/music_directory.go
Normal file
287
internal/domains/filesystem/music_directory.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Any non-root directory is a MusicDirectory
|
||||
|
||||
type MusicDir struct {
|
||||
fs.Inode
|
||||
|
||||
f *FS
|
||||
path string
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeGetattrer)((*MusicDir)(nil))
|
||||
_ = (fs.NodeLookuper)((*MusicDir)(nil))
|
||||
_ = (fs.NodeReaddirer)((*MusicDir)(nil))
|
||||
_ = (fs.NodeCreater)((*MusicDir)(nil))
|
||||
_ = (fs.NodeGetxattrer)((*MusicDir)(nil))
|
||||
_ = (fs.NodeSetxattrer)((*MusicDir)(nil))
|
||||
_ = (fs.NodeRemovexattrer)((*MusicDir)(nil))
|
||||
_ = (fs.NodeListxattrer)((*MusicDir)(nil))
|
||||
)
|
||||
|
||||
func (d *MusicDir) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
out.Mode = fuse.S_IFDIR | 0755
|
||||
out.Nlink = 2 // Minimum . and ..
|
||||
out.Ino = d.StableAttr().Ino
|
||||
out.Size = 4096
|
||||
|
||||
// Get actual mod time from filesystem if possible
|
||||
if info, err := os.Stat(d.path); err == nil {
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
|
||||
// Count actual subdirectories for accurate nlink
|
||||
if entries, err := os.ReadDir(d.path); err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
out.Nlink++
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
now := uint64(time.Now().Unix())
|
||||
out.Mtime = now
|
||||
out.Atime = now
|
||||
out.Ctime = now
|
||||
}
|
||||
|
||||
out.Blocks = 1
|
||||
out.Blksize = 512
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *MusicDir) Getxattr(ctx context.Context, attr string, dest []byte) (uint32, syscall.Errno) {
|
||||
// Same implementation as RootDir
|
||||
switch attr {
|
||||
case "user.org.netatalk.Metadata":
|
||||
fallthrough
|
||||
case "com.apple.FinderInfo":
|
||||
fallthrough
|
||||
case "com.apple.ResourceFork":
|
||||
if len(dest) > 0 {
|
||||
return 0, 0
|
||||
}
|
||||
return 0, 0
|
||||
default:
|
||||
return 0, syscall.ENODATA
|
||||
}
|
||||
}
|
||||
|
||||
func (d *MusicDir) Setxattr(ctx context.Context, attr string, data []byte, flags uint32) syscall.Errno {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *MusicDir) Removexattr(ctx context.Context, attr string) syscall.Errno {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *MusicDir) Listxattr(ctx context.Context, dest []byte) (uint32, syscall.Errno) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func (d *MusicDir) Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (*fs.Inode, fs.FileHandle, uint32, syscall.Errno) {
|
||||
if d.f.isiTunesMetadata(name) {
|
||||
metaPath := filepath.Join(d.f.metadataDir, name)
|
||||
|
||||
file, err := os.Create(metaPath)
|
||||
if err != nil {
|
||||
return nil, nil, 0, syscall.EIO
|
||||
}
|
||||
|
||||
ch := d.NewInode(
|
||||
ctx,
|
||||
d.f.NewMusicAppMetadataFile(metaPath),
|
||||
fs.StableAttr{
|
||||
Mode: fuse.S_IFREG,
|
||||
Ino: d.f.nextInode(),
|
||||
},
|
||||
)
|
||||
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
out.Size = 0
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
|
||||
return ch, &File{file: file}, fuse.FOPEN_DIRECT_IO, 0
|
||||
}
|
||||
|
||||
return nil, nil, 0, syscall.EPERM
|
||||
}
|
||||
|
||||
func (d *MusicDir) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||
// Handle .m4a virtual files
|
||||
if strings.HasSuffix(strings.ToLower(name), ".m4a") {
|
||||
flacName := name[:len(name)-4] + ".flac"
|
||||
flacPath := filepath.Join(d.path, flacName)
|
||||
|
||||
if _, err := os.Stat(flacPath); err == nil {
|
||||
ch := d.NewInode(
|
||||
ctx,
|
||||
d.f.NewMusicFile(flacPath, name, false),
|
||||
fs.StableAttr{
|
||||
Mode: fuse.S_IFREG,
|
||||
Ino: d.f.nextInode(),
|
||||
},
|
||||
)
|
||||
|
||||
out.Mode = fuse.S_IFREG | 0444
|
||||
out.Nlink = 1
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
|
||||
if size, err := d.f.cacher.GetStat(flacPath); err == nil {
|
||||
out.Size = uint64(size)
|
||||
} else {
|
||||
out.Size = 0
|
||||
}
|
||||
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
|
||||
return ch, 0
|
||||
}
|
||||
}
|
||||
|
||||
// Check real file or directory
|
||||
fullPath := filepath.Join(d.path, name)
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return nil, syscall.ENOENT
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
ch := d.NewInode(
|
||||
ctx, d.f.NewMusicDirectory(fullPath),
|
||||
fs.StableAttr{
|
||||
Mode: fuse.S_IFDIR,
|
||||
Ino: d.f.nextInode(),
|
||||
},
|
||||
)
|
||||
|
||||
out.Mode = fuse.S_IFDIR | 0755
|
||||
out.Nlink = 2
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
out.Size = 4096
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
|
||||
return ch, 0
|
||||
}
|
||||
|
||||
// Regular file (non-FLAC)
|
||||
isMeta := d.f.isiTunesMetadata(name)
|
||||
ch := d.NewInode(ctx, d.f.NewMusicFile(fullPath, name, isMeta),
|
||||
fs.StableAttr{
|
||||
Mode: fuse.S_IFREG,
|
||||
Ino: d.f.nextInode(),
|
||||
},
|
||||
)
|
||||
|
||||
if isMeta {
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
} else {
|
||||
out.Mode = fuse.S_IFREG | 0444
|
||||
}
|
||||
|
||||
out.Nlink = 1
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
out.Size = uint64(info.Size())
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
|
||||
return ch, 0
|
||||
}
|
||||
|
||||
func (d *MusicDir) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) {
|
||||
d.f.app.Logger().WithField("path", d.path).Debug("Readdir called on directory")
|
||||
|
||||
var dirEntries []fuse.DirEntry
|
||||
|
||||
dirEntries = append(dirEntries, fuse.DirEntry{
|
||||
Name: ".",
|
||||
Mode: fuse.S_IFDIR | 0755,
|
||||
Ino: d.StableAttr().Ino,
|
||||
})
|
||||
dirEntries = append(dirEntries, fuse.DirEntry{
|
||||
Name: "..",
|
||||
Mode: fuse.S_IFDIR | 0755,
|
||||
Ino: 1, // Parent (root) inode
|
||||
})
|
||||
|
||||
entries, err := os.ReadDir(d.path)
|
||||
if err != nil {
|
||||
d.f.app.Logger().WithError(err).WithField("path", d.path).Error(
|
||||
"Error reading directory",
|
||||
)
|
||||
|
||||
return fs.NewListDirStream(dirEntries), 0
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
|
||||
if strings.HasPrefix(name, ".") && !d.f.isiTunesMetadata(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
mode := fuse.S_IFREG | 0444
|
||||
if entry.IsDir() {
|
||||
mode = fuse.S_IFDIR | 0755
|
||||
}
|
||||
|
||||
// Convert .flac to .m4a in directory listing
|
||||
if strings.HasSuffix(strings.ToLower(name), ".flac") {
|
||||
name = name[:len(name)-5] + ".m4a"
|
||||
if !d.f.isiTunesMetadata(name) {
|
||||
mode = fuse.S_IFREG | 0644
|
||||
}
|
||||
} else if !d.f.isiTunesMetadata(name) {
|
||||
mode = fuse.S_IFREG | 0644
|
||||
}
|
||||
|
||||
dirEntries = append(dirEntries, fuse.DirEntry{
|
||||
Name: name,
|
||||
Mode: uint32(mode),
|
||||
Ino: d.f.nextInode(),
|
||||
})
|
||||
}
|
||||
|
||||
d.f.app.Logger().WithFields(logrus.Fields{
|
||||
"path": d.path,
|
||||
"directory entries": len(dirEntries),
|
||||
}).Debug("Returning directory entries")
|
||||
|
||||
return fs.NewListDirStream(dirEntries), 0
|
||||
}
|
||||
|
||||
func (f *FS) NewMusicDirectory(path string) *MusicDir {
|
||||
return &MusicDir{
|
||||
f: f,
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
146
internal/domains/filesystem/music_file.go
Normal file
146
internal/domains/filesystem/music_file.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type MusicFile struct {
|
||||
fs.Inode
|
||||
|
||||
f *FS
|
||||
sourcePath string
|
||||
virtualName string
|
||||
isMetaFile bool
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeGetattrer)((*MusicFile)(nil))
|
||||
_ = (fs.NodeOpener)((*MusicFile)(nil))
|
||||
_ = (fs.NodeSetattrer)((*MusicFile)(nil))
|
||||
)
|
||||
|
||||
func (f *MusicFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
if f.isMetaFile {
|
||||
metaPath := filepath.Join(f.f.metadataDir, f.virtualName)
|
||||
|
||||
if info, err := os.Stat(metaPath); err == nil {
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = f.StableAttr().Ino
|
||||
out.Size = uint64(info.Size())
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
} else {
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = f.StableAttr().Ino
|
||||
out.Size = 0
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
out.Mode = fuse.S_IFREG | 0444
|
||||
out.Nlink = 1
|
||||
out.Ino = f.StableAttr().Ino
|
||||
out.Blocks = 1
|
||||
|
||||
if size, err := f.f.cacher.GetStat(f.sourcePath); err == nil {
|
||||
out.Size = uint64(size)
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
} else {
|
||||
out.Size = 0
|
||||
}
|
||||
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (f *MusicFile) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
|
||||
if f.isMetaFile {
|
||||
metaPath := filepath.Join(f.f.metadataDir, f.virtualName)
|
||||
if info, err := os.Stat(metaPath); err == nil {
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = f.StableAttr().Ino
|
||||
out.Size = uint64(info.Size())
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
} else {
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = f.StableAttr().Ino
|
||||
out.Size = 0
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
return syscall.EPERM
|
||||
}
|
||||
|
||||
func (f *MusicFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
if f.isMetaFile {
|
||||
metaPath := filepath.Join(f.f.metadataDir, f.virtualName)
|
||||
|
||||
file, err := os.OpenFile(metaPath, int(flags), 0644)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
file, err = os.Create(metaPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, syscall.EIO
|
||||
}
|
||||
|
||||
return &File{file: file}, fuse.FOPEN_DIRECT_IO, 0
|
||||
}
|
||||
|
||||
if flags&fuse.O_ANYWRITE != 0 {
|
||||
return nil, 0, syscall.EPERM
|
||||
}
|
||||
|
||||
entry, err := f.f.cacher.GetFileDTO(f.sourcePath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to convert %s to ALAC: %v", filepath.Base(f.sourcePath), err)
|
||||
|
||||
return nil, 0, syscall.EIO
|
||||
}
|
||||
|
||||
file, err := os.Open(entry.Path)
|
||||
if err != nil {
|
||||
return nil, 0, syscall.EIO
|
||||
}
|
||||
|
||||
return &File{file: file}, fuse.FOPEN_KEEP_CACHE, 0
|
||||
}
|
||||
|
||||
func (f *FS) NewMusicFile(sourcePath, virtualName string, isMetaFile bool) *MusicFile {
|
||||
return &MusicFile{
|
||||
f: f,
|
||||
sourcePath: sourcePath,
|
||||
virtualName: virtualName,
|
||||
isMetaFile: isMetaFile,
|
||||
}
|
||||
}
|
||||
323
internal/domains/filesystem/root.go
Normal file
323
internal/domains/filesystem/root.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type RootDirectory struct {
|
||||
fs.Inode
|
||||
|
||||
f *FS
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeGetattrer)((*RootDirectory)(nil))
|
||||
_ = (fs.NodeLookuper)((*RootDirectory)(nil))
|
||||
_ = (fs.NodeReaddirer)((*RootDirectory)(nil))
|
||||
_ = (fs.NodeCreater)((*RootDirectory)(nil))
|
||||
_ = (fs.NodeGetxattrer)((*RootDirectory)(nil))
|
||||
_ = (fs.NodeSetxattrer)((*RootDirectory)(nil))
|
||||
_ = (fs.NodeRemovexattrer)((*RootDirectory)(nil))
|
||||
_ = (fs.NodeListxattrer)((*RootDirectory)(nil))
|
||||
)
|
||||
|
||||
func (r *RootDirectory) Create(
|
||||
ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut,
|
||||
) (*fs.Inode, fs.FileHandle, uint32, syscall.Errno) {
|
||||
if r.f.isiTunesMetadata(name) {
|
||||
metaPath := filepath.Join(r.f.metadataDir, name)
|
||||
|
||||
file, err := os.Create(metaPath)
|
||||
if err != nil {
|
||||
return nil, nil, 0, syscall.EIO
|
||||
}
|
||||
|
||||
ch := r.NewInode(
|
||||
ctx,
|
||||
r.f.NewMusicAppMetadataFile(metaPath),
|
||||
fs.StableAttr{
|
||||
Mode: fuse.S_IFREG,
|
||||
Ino: r.f.nextInode(),
|
||||
},
|
||||
)
|
||||
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
out.Size = 0
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
|
||||
return ch, &File{file: file}, fuse.FOPEN_DIRECT_IO, 0
|
||||
}
|
||||
|
||||
return nil, nil, 0, syscall.EPERM
|
||||
}
|
||||
|
||||
func (r *RootDirectory) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||
if r.f.isiTunesMetadata(name) {
|
||||
metaPath := filepath.Join(r.f.metadataDir, name)
|
||||
ch := r.NewInode(
|
||||
ctx,
|
||||
r.f.NewMusicAppMetadataFile(metaPath),
|
||||
fs.StableAttr{
|
||||
Mode: fuse.S_IFREG,
|
||||
Ino: r.f.nextInode(),
|
||||
},
|
||||
)
|
||||
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
out.Nlink = 1
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
|
||||
if info, err := os.Stat(metaPath); err == nil {
|
||||
out.Size = uint64(info.Size())
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
} else {
|
||||
out.Size = 0
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
}
|
||||
|
||||
// Calculate blocks
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
|
||||
return ch, 0
|
||||
}
|
||||
|
||||
// Handle .m4a virtual files
|
||||
if strings.HasSuffix(strings.ToLower(name), ".m4a") {
|
||||
flacName := name[:len(name)-4] + ".flac"
|
||||
flacPath := filepath.Join(r.f.sourceDir, flacName)
|
||||
|
||||
if _, err := os.Stat(flacPath); err == nil {
|
||||
ch := r.NewInode(
|
||||
ctx,
|
||||
r.f.NewMusicFile(flacPath, name, false),
|
||||
fs.StableAttr{
|
||||
Mode: fuse.S_IFREG,
|
||||
Ino: r.f.nextInode(),
|
||||
},
|
||||
)
|
||||
|
||||
out.Mode = fuse.S_IFREG | 0444
|
||||
out.Nlink = 1
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
|
||||
if size, err := r.f.cacher.GetStat(flacPath); err == nil {
|
||||
out.Size = uint64(size)
|
||||
} else {
|
||||
out.Size = 0
|
||||
}
|
||||
|
||||
out.Mtime = uint64(time.Now().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
|
||||
return ch, 0
|
||||
}
|
||||
}
|
||||
|
||||
// Check real file or directory
|
||||
fullPath := filepath.Join(r.f.sourceDir, name)
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return nil, syscall.ENOENT
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
ch := r.NewInode(ctx, r.f.NewMusicDirectory(fullPath), fs.StableAttr{
|
||||
Mode: fuse.S_IFDIR,
|
||||
Ino: r.f.nextInode(),
|
||||
})
|
||||
|
||||
out.Mode = fuse.S_IFDIR | 0755
|
||||
out.Nlink = 2 // Minimum . and ..
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
out.Size = 4096
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = 1
|
||||
|
||||
return ch, 0
|
||||
}
|
||||
|
||||
// Regular file (non-FLAC)
|
||||
isMeta := r.f.isiTunesMetadata(name)
|
||||
ch := r.NewInode(ctx, r.f.NewMusicFile(fullPath, name, isMeta), fs.StableAttr{
|
||||
Mode: fuse.S_IFREG,
|
||||
Ino: r.f.nextInode(),
|
||||
})
|
||||
|
||||
if isMeta {
|
||||
out.Mode = fuse.S_IFREG | 0644
|
||||
} else {
|
||||
out.Mode = fuse.S_IFREG | 0444
|
||||
}
|
||||
|
||||
out.Nlink = 1
|
||||
out.Ino = ch.StableAttr().Ino
|
||||
out.Size = uint64(info.Size())
|
||||
out.Mtime = uint64(info.ModTime().Unix())
|
||||
out.Atime = out.Mtime
|
||||
out.Ctime = out.Mtime
|
||||
out.Blocks = (out.Size + 511) / 512
|
||||
|
||||
return ch, 0
|
||||
}
|
||||
|
||||
func (r *RootDirectory) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) {
|
||||
r.f.app.Logger().WithField("path", r.f.sourceDir).Debug("Readdir called on directory")
|
||||
|
||||
var dirEntries []fuse.DirEntry
|
||||
|
||||
// Always include . and .. first
|
||||
dirEntries = append(dirEntries, fuse.DirEntry{
|
||||
Name: ".",
|
||||
Mode: fuse.S_IFDIR | 0755,
|
||||
Ino: 1, // Root inode
|
||||
})
|
||||
dirEntries = append(dirEntries, fuse.DirEntry{
|
||||
Name: "..",
|
||||
Mode: fuse.S_IFDIR | 0755,
|
||||
Ino: 1,
|
||||
})
|
||||
|
||||
// Read actual directory contents
|
||||
entries, err := os.ReadDir(r.f.sourceDir)
|
||||
if err != nil {
|
||||
r.f.app.Logger().WithError(err).WithField("path", r.f.sourceDir).Error(
|
||||
"Error reading directory",
|
||||
)
|
||||
return fs.NewListDirStream(dirEntries), 0
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
|
||||
if strings.HasPrefix(name, ".") && !r.f.isiTunesMetadata(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
mode := fuse.S_IFREG | 0444
|
||||
if entry.IsDir() {
|
||||
mode = fuse.S_IFDIR | 0755
|
||||
}
|
||||
|
||||
// Convert .flac to .m4a in directory listing
|
||||
if strings.HasSuffix(strings.ToLower(name), ".flac") {
|
||||
name = name[:len(name)-5] + ".m4a"
|
||||
}
|
||||
|
||||
mode = fuse.S_IFREG | 0644
|
||||
|
||||
dirEntries = append(dirEntries, fuse.DirEntry{
|
||||
Name: name,
|
||||
Mode: uint32(mode),
|
||||
Ino: r.f.nextInode(),
|
||||
})
|
||||
}
|
||||
|
||||
r.f.app.Logger().WithFields(logrus.Fields{
|
||||
"path": r.f.sourceDir,
|
||||
"directory entries": len(dirEntries),
|
||||
}).Debug("Returning directory entries")
|
||||
|
||||
return fs.NewListDirStream(dirEntries), 0
|
||||
}
|
||||
|
||||
func (r *RootDirectory) Getattr(
|
||||
ctx context.Context, f fs.FileHandle, out *fuse.AttrOut,
|
||||
) syscall.Errno {
|
||||
// Set basic directory attributes
|
||||
out.Mode = fuse.S_IFDIR | 0755
|
||||
|
||||
// Set nlink to at least 2 (for . and ..)
|
||||
out.Nlink = 2
|
||||
|
||||
// Root directory typically has inode 1
|
||||
out.Ino = 1
|
||||
|
||||
// Set size to typical directory size
|
||||
out.Size = 4096
|
||||
|
||||
// Set timestamps
|
||||
now := uint64(time.Now().Unix())
|
||||
out.Mtime = now
|
||||
out.Atime = now
|
||||
out.Ctime = now
|
||||
|
||||
// Set blocks (1 block of 512 bytes each = 512 bytes)
|
||||
out.Blocks = 1
|
||||
|
||||
// Set block size
|
||||
out.Blksize = 512
|
||||
|
||||
// Count actual subdirectories for accurate nlink
|
||||
if entries, err := os.ReadDir(r.f.sourceDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
out.Nlink++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *RootDirectory) Getxattr(
|
||||
ctx context.Context, attr string, dest []byte,
|
||||
) (uint32, syscall.Errno) {
|
||||
// Handle common macOS/Netatalk xattrs
|
||||
switch attr {
|
||||
case "user.org.netatalk.Metadata":
|
||||
fallthrough
|
||||
case "com.apple.FinderInfo":
|
||||
fallthrough
|
||||
case "com.apple.ResourceFork":
|
||||
// Return empty data
|
||||
if len(dest) > 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
return 0, 0
|
||||
default:
|
||||
return 0, syscall.ENODATA
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootDirectory) Setxattr(ctx context.Context, attr string, data []byte, flags uint32) syscall.Errno {
|
||||
// Silently accept xattr writes (ignore them)
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *RootDirectory) Removexattr(ctx context.Context, attr string) syscall.Errno {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *RootDirectory) Listxattr(ctx context.Context, dest []byte) (uint32, syscall.Errno) {
|
||||
// Return empty xattr list
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func (f *FS) NewRootDirectory() *RootDirectory {
|
||||
return &RootDirectory{
|
||||
f: f,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user