Initial commit

Proof-of-concept implementation. Bugs will occur.
This commit is contained in:
2026-02-12 01:18:46 +03:00
commit 13ac06c14b
553 changed files with 253003 additions and 0 deletions

View 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
}

View 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")
)

View 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
}

View 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
}

View File

@@ -0,0 +1,7 @@
package filesystem
import "sync/atomic"
func (f *FS) nextInode() uint64 {
return atomic.AddUint64(&f.inodeCounter, 1)
}

View 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()
}
}

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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,
}
}