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,10 @@
package domains
import "source.hodakov.me/hdkv/faketunes/internal/domains/cacher/dto"
const CacherName = "cacher"
type Cacher interface {
GetStat(sourcePath string) (int64, error)
GetFileDTO(sourcePath string) (*dto.CacheItem, error)
}

View File

@@ -0,0 +1,56 @@
package cacher
import (
"fmt"
"sync"
"source.hodakov.me/hdkv/faketunes/internal/application"
"source.hodakov.me/hdkv/faketunes/internal/domains"
"source.hodakov.me/hdkv/faketunes/internal/domains/cacher/models"
)
var (
_ domains.Cacher = new(Cacher)
_ domains.Domain = new(Cacher)
)
type Cacher struct {
app *application.App
transcoder domains.Transcoder
cacheDir string
cacheMutex sync.RWMutex
currentSize int64
maxSize int64
items map[string]*models.CacheItem
stat map[string]*models.CacherStat
}
func New(app *application.App) *Cacher {
return &Cacher{
app: app,
cacheDir: app.Config().Paths.Destination + "./.cache",
maxSize: app.Config().FakeTunes.CacheSize * 1024 * 1024,
items: make(map[string]*models.CacheItem, 0),
stat: make(map[string]*models.CacherStat, 0),
}
}
func (c *Cacher) ConnectDependencies() error {
transcoder, ok := c.app.RetrieveDomain(domains.TranscoderName).(domains.Transcoder)
if !ok {
return fmt.Errorf(
"%w: %w (%s)", ErrCacher, ErrConnectDependencies,
"transcoder domain interface conversion failed",
)
}
c.transcoder = transcoder
return nil
}
func (c *Cacher) Start() error {
return nil
}

View File

@@ -0,0 +1,37 @@
package cacher
import (
"fmt"
"os"
"time"
)
func (c *Cacher) cleanup() error {
for c.currentSize > c.maxSize && len(c.items) > 0 {
var (
itemKey string
itemSize int64
oldestTime time.Time
)
for key, item := range c.items {
if itemKey == "" || item.Updated.Before(oldestTime) {
itemKey = key
oldestTime = item.Updated
itemSize = item.Size
}
}
if itemKey != "" {
err := os.Remove(c.items[itemKey].Path)
if err != nil {
return fmt.Errorf("%w: %w (%w)", ErrCacher, ErrFailedToDeleteCachedFile, err)
}
delete(c.items, itemKey)
c.currentSize -= itemSize
}
}
return nil
}

View File

@@ -0,0 +1,9 @@
package dto
import "time"
type CacheItem struct {
Path string
Size int64
Updated time.Time
}

View File

@@ -0,0 +1,11 @@
package cacher
import "errors"
var (
ErrCacher = errors.New("cacher")
ErrConnectDependencies = errors.New("failed to connect dependencies")
ErrFailedToDeleteCachedFile = errors.New("failed to delete cached file")
ErrFailedToGetSourceFile = errors.New("failed to get source file")
ErrFailedToTranscodeFile = errors.New("failed to transcode file")
)

View File

@@ -0,0 +1,98 @@
package cacher
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"time"
"source.hodakov.me/hdkv/faketunes/internal/domains/cacher/dto"
"source.hodakov.me/hdkv/faketunes/internal/domains/cacher/models"
)
// GetFileDTO gets the ALAC file from cache or transcodes one with transcoder if needed.
func (c *Cacher) GetFileDTO(sourcePath string) (*dto.CacheItem, error) {
item, err := c.getFile(sourcePath)
if err != nil {
return nil, fmt.Errorf("%w: %w (%w)", ErrCacher, ErrFailedToGetSourceFile, err)
}
return models.CacheItemModelToDTO(item), nil
}
func (c *Cacher) getFile(sourcePath string) (*models.CacheItem, error) {
sourceFileInfo, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("%w: %w (%w)", ErrCacher, ErrFailedToGetSourceFile, err)
}
keyData := fmt.Sprintf("%s:%d", sourcePath, sourceFileInfo.ModTime().UnixNano())
hash := md5.Sum([]byte(keyData))
cacheKey := fmt.Sprintf("%x", hash)
cacheFilePath := filepath.Join(c.cacheDir, cacheKey+".m4a")
c.cacheMutex.Lock()
defer c.cacheMutex.Unlock()
// Check if file information exists in cache
if item, ok := c.items[cacheKey]; ok {
if _, err := os.Stat(item.Path); err != nil {
// File exists in cache and on disk
item.Updated = time.Now().UTC()
c.updateCachedStat(sourcePath, item.Size)
return item, nil
}
}
// Check if file exists on disk but information about it doesn't exist in
// the memory (for example, after application restart).
if cachedFileInfo, err := os.Stat(cacheFilePath); err == nil {
// Verify that the file on disk is newer than the source file and has content.
// If that's the case, return the item information and store it in memory.
if cachedFileInfo.ModTime().After(sourceFileInfo.ModTime()) &&
cachedFileInfo.Size() > 1024 {
item := &models.CacheItem{
Path: cacheFilePath,
Size: cachedFileInfo.Size(),
Updated: time.Now().UTC(),
}
c.items[cacheKey] = item
c.currentSize += cachedFileInfo.Size()
c.updateCachedStat(sourcePath, item.Size)
return item, nil
}
}
// File does not exist on disk, need to transcode.
// Register in the queue
c.transcoder.QueueChannel() <- struct{}{}
defer func() {
<-c.transcoder.QueueChannel()
}()
// Convert file
size, err := c.transcoder.Convert(sourcePath, cacheFilePath)
if err != nil {
return nil, fmt.Errorf("%w: %w (%w)", ErrCacher, ErrFailedToTranscodeFile, err)
}
// Add converted file information to cache
item := &models.CacheItem{
Path: cacheFilePath,
Size: size,
Updated: time.Now(),
}
c.items[cacheKey] = item
c.currentSize += size
c.updateCachedStat(sourcePath, size)
// TODO: run cleanup on inotify events.
c.cleanup()
return item, nil
}

View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"source.hodakov.me/hdkv/faketunes/internal/domains/cacher/dto"
)
type CacheItem struct {
Path string
Size int64
Updated time.Time
}
func CacheItemModelToDTO(item *CacheItem) *dto.CacheItem {
return &dto.CacheItem{
Path: item.Path,
Size: item.Size,
Updated: item.Updated,
}
}

View File

@@ -0,0 +1,9 @@
package models
import "time"
// CacherStat is representing information about a single object size in cache.
type CacherStat struct {
Size int64
Created time.Time
}

View File

@@ -0,0 +1,64 @@
package cacher
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"time"
"source.hodakov.me/hdkv/faketunes/internal/domains/cacher/models"
)
// getStat returns file size without triggering conversion (for ls/stat)
func (c *Cacher) GetStat(sourcePath string) (int64, error) {
// First check cache
if size, ok := c.getCachedStat(sourcePath); ok {
return size, nil
}
// Check if we have a cached converted file
info, err := os.Stat(sourcePath)
if err != nil {
return 0, err
}
keyData := fmt.Sprintf("%s:%d", sourcePath, info.ModTime().UnixNano())
hash := md5.Sum([]byte(keyData))
key := fmt.Sprintf("%x", hash)
cachePath := filepath.Join(c.cacheDir, key+".m4a")
// Check if converted file exists and is valid
if cacheInfo, err := os.Stat(cachePath); err == nil {
if cacheInfo.ModTime().After(info.ModTime()) && cacheInfo.Size() > 1024 {
c.updateCachedStat(sourcePath, cacheInfo.Size())
return cacheInfo.Size(), nil
}
}
// Return estimated size (FLAC file size as placeholder)
return info.Size(), nil
}
// updateCachedStat updates the stat cache
func (c *Cacher) updateCachedStat(sourcePath string, size int64) {
c.cacheMutex.Lock()
defer c.cacheMutex.Unlock()
c.stat[sourcePath] = &models.CacherStat{
Size: size,
Created: time.Now(),
}
}
// getCachedStat returns cached file stats
func (c *Cacher) getCachedStat(sourcePath string) (int64, bool) {
c.cacheMutex.RLock()
defer c.cacheMutex.RUnlock()
if stat, ok := c.stat[sourcePath]; ok {
return stat.Size, true
}
return 0, false
}

View File

@@ -0,0 +1,6 @@
package domains
type Domain interface {
ConnectDependencies() error
Start() error
}

View File

@@ -0,0 +1,5 @@
package domains
const FilesystemName = "filesystem"
type Filesystem interface{}

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

View File

@@ -0,0 +1,8 @@
package domains
const TranscoderName = "transcoder"
type Transcoder interface {
Convert(sourcePath, destinationPath string) (int64, error)
QueueChannel() chan struct{}
}

View File

@@ -0,0 +1,40 @@
package transcoder
import (
"os"
"path/filepath"
)
func (t *Transcoder) findAlbumArt(path string) string {
// Common album art filenames (in order of preference)
artFiles := []string{
"albumart.jpg",
"AlbumArt.jpg",
"cover.jpg",
"Cover.jpg",
"folder.jpg",
"Folder.jpg",
"albumart.jpeg",
"cover.jpeg",
"folder.jpeg",
"albumart.png",
"cover.png",
"folder.png",
"albumart.gif",
"cover.gif",
".albumart.jpg",
".cover.jpg",
"AlbumArtwork.jpg",
"album.jpg",
"Album.jpg",
}
for _, artFile := range artFiles {
fullPath := filepath.Join(path, artFile)
if _, err := os.Stat(fullPath); err == nil {
return fullPath
}
}
return ""
}

View File

@@ -0,0 +1,212 @@
package transcoder
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/sirupsen/logrus"
)
const (
defaultSampleRate = 48000
defaultBitDepth = 16
)
// Convert converts the file from FLAC to ALAC using ffmpeg.
// It embeds all required metadata and places the file in the desired destination.
// On success, it returns the transcoded file's size.
func (t *Transcoder) Convert(sourcePath, destinationPath string) (int64, error) {
t.app.Logger().WithFields(logrus.Fields{
"source file": sourcePath,
"destination": destinationPath,
}).Info("Transcoding file using ffmpeg...")
sourceAlbumDir := filepath.Dir(sourcePath)
albumArt := t.findAlbumArt(sourceAlbumDir)
hasAlbumArt := albumArt != ""
sortArtist := t.extractAlbumArtist(sourcePath, sourceAlbumDir)
sampleRate := defaultSampleRate
bitDepth := defaultBitDepth
if hasAlbumArt {
t.app.Logger().WithField("album art path", albumArt).Debug("Found album art")
}
t.app.Logger().WithField("sort artist", sortArtist).Debug(
"Setting sorting artist for iTunes",
)
sourceAnalyzeCmd := exec.Command(
"ffprobe",
"-v", "quiet",
"-show_streams",
"-select_streams", "a:0",
"-of", "csv=p=0",
sourcePath,
)
analyzeOutput, err := sourceAnalyzeCmd.Output()
if err == nil {
// Investiage bit depth and sample rate from ffprobe output.
// We need that to make sure we don't oversample files that are lower
// than the default sample rate and bit depth.
lines := strings.Split(strings.TrimSpace(string(analyzeOutput)), "\n")
for _, line := range lines {
if strings.Contains(line, "audio") {
parts := strings.Split(line, ",")
if len(parts) >= 6 {
// Get sample rate
if sr, err := strconv.Atoi(parts[2]); err == nil && sr > 0 {
sampleRate = sr
}
// Get bit depth from sample_fmt or bits_per_raw_sample
sampleFmt := parts[4]
if strings.Contains(sampleFmt, "s32") || strings.Contains(sampleFmt, "flt") {
bitDepth = 32
} else if strings.Contains(sampleFmt, "s64") || strings.Contains(sampleFmt, "dbl") {
bitDepth = 64
} else if len(parts) >= 6 && parts[5] != "N/A" && parts[5] != "" {
if bd, err := strconv.Atoi(parts[5]); err == nil && bd > 0 {
bitDepth = bd
}
}
}
break // We only need the first audio stream
}
}
}
t.app.Logger().WithFields(logrus.Fields{
"bit depth": bitDepth,
"sample rate": sampleRate,
}).Info("Detected source file sample rate and bit depth")
needsDownsample := sampleRate > defaultSampleRate
needsBitReduce := bitDepth > defaultBitDepth
if needsDownsample {
t.app.Logger().WithFields(logrus.Fields{
"new sample rate": defaultSampleRate,
"old sample rate": sampleRate,
}).Info("Sample rate of the destination file will be changed")
}
if needsBitReduce {
t.app.Logger().WithFields(logrus.Fields{
"new bit depth": defaultBitDepth,
"old bit depth": bitDepth,
}).Info("Bit depth of the destination file will be changed")
}
ffmpegArgs := make([]string, 0)
// Add sources
ffmpegArgs = append(ffmpegArgs, "-i", sourcePath)
if hasAlbumArt {
ffmpegArgs = append(ffmpegArgs, "-i", albumArt)
}
// Map streams and set codecs
if hasAlbumArt {
ffmpegArgs = append(ffmpegArgs,
"-map", "0:a", // Map audio from first input
"-map", "1", // Map image from second input
"-c:a", "alac", // ALAC codec for audio
"-c:v", "copy", // Copy image without re-encoding
"-disposition:v", "attached_pic",
)
} else {
ffmpegArgs = append(ffmpegArgs,
"-map", "0:a",
"-c:a", "alac",
)
}
// Handle downsampling
if needsDownsample {
ffmpegArgs = append(
ffmpegArgs,
"-af", "aresample=48000:resampler=soxr:precision=28",
)
} else {
ffmpegArgs = append(ffmpegArgs, "-ar", fmt.Sprintf("%d", sampleRate))
}
if needsBitReduce {
// Reduce to 16-bit with good dithering
ffmpegArgs = append(ffmpegArgs,
"-sample_fmt", "s16p",
"-dither_method", "triangular",
)
}
// Handle metadata copying and sort_artist filling
ffmpegArgs = append(ffmpegArgs,
"-map_metadata", "0",
"-metadata", fmt.Sprintf("sort_artist=%s", t.escapeMetadata(sortArtist)),
"-write_id3v2", "1",
"-id3v2_version", "3",
destinationPath,
"-y",
"-loglevel", "error",
"-stats",
)
t.app.Logger().WithField(
"ffmpeg command", "ffmpeg "+strings.Join(ffmpegArgs, " "),
).Debug("FFMpeg parameters")
ffmpeg := exec.Command("ffmpeg", ffmpegArgs...)
var stderr bytes.Buffer
ffmpeg.Stderr = &stderr
if err := ffmpeg.Run(); err != nil {
t.app.Logger().WithError(err).Error("Failed to invoke ffmpeg!")
t.app.Logger().WithField("ffmpeg stderr", stderr.String()).Debug("Got ffmpeg stderr")
return 0, fmt.Errorf("%w: %w (%w)", ErrTranscoder, ErrTranscodeError, err)
}
// Verify that the result file is saved to cache directory
transcodedFileStat, err := os.Stat(destinationPath)
if err != nil {
t.app.Logger().WithError(err).WithFields(logrus.Fields{
"source file": sourcePath,
"destination": destinationPath,
}).Error("Transcoded file not found (transcode error?). Check the logs for details")
return 0, fmt.Errorf("%w: %w (%w)", ErrTranscoder, ErrTranscodedFileNotFound, err)
}
// Discard the file if it's less than 1 kilobyte: it's probably a transcode
// error
if transcodedFileStat.Size() < 1024 {
t.app.Logger().WithFields(logrus.Fields{
"source file": sourcePath,
"destination": destinationPath,
"transcoded file size": transcodedFileStat.Size(),
}).Error("Transcoded file not found (transcode error?). Check the logs for details")
return 0, fmt.Errorf(
"%w: %w (%s)",
ErrTranscoder, ErrTranscodedFileNotFound,
fmt.Sprintf("size is %d bytes, less than 1 kilobyte", transcodedFileStat.Size()),
)
}
t.app.Logger().WithFields(logrus.Fields{
"source file": sourcePath,
"destination": destinationPath,
"destination size": transcodedFileStat.Size(),
}).Info("File transcoded successfully")
return transcodedFileStat.Size(), nil
}

View File

@@ -0,0 +1,10 @@
package transcoder
import "errors"
var (
ErrTranscoder = errors.New("transcoder")
ErrTranscodeError = errors.New("transcode error")
ErrTranscodedFileIsTooSmall = errors.New("transcoded file is too small")
ErrTranscodedFileNotFound = errors.New("transcoded file not found")
)

View File

@@ -0,0 +1,41 @@
package transcoder
import (
"path/filepath"
"strings"
)
func (t *Transcoder) escapeMetadata(item string) string {
// Escape quotes and backslashes for FFmpeg metadata
item = strings.ReplaceAll(item, `\`, `\\`)
item = strings.ReplaceAll(item, `"`, `\"`)
item = strings.ReplaceAll(item, `'`, `\'`)
// Also escape semicolons and equals signs
item = strings.ReplaceAll(item, `;`, `\;`)
item = strings.ReplaceAll(item, `=`, `\=`)
return item
}
func (t *Transcoder) extractAlbumArtist(filePath, sourceDir string) string {
// Get relative path from source directory
relPath, err := filepath.Rel(sourceDir, filePath)
if err != nil {
return "Unknown Artist"
}
// Split path into components
parts := strings.Split(relPath, string(filepath.Separator))
// Album artist is the first directory after source
// e.g., /source/Artist/Album/01 - Track Name.flac
if len(parts) >= 2 {
artist := parts[0]
artist = strings.TrimSpace(artist)
return artist
}
return "Unknown Artist"
}

View File

@@ -0,0 +1,5 @@
package transcoder
func (t *Transcoder) QueueChannel() chan struct{} {
return t.transcodeQueue
}

View File

@@ -0,0 +1,31 @@
package transcoder
import (
"source.hodakov.me/hdkv/faketunes/internal/application"
"source.hodakov.me/hdkv/faketunes/internal/domains"
)
var (
_ domains.Transcoder = new(Transcoder)
_ domains.Domain = new(Transcoder)
)
type Transcoder struct {
app *application.App
transcodeQueue chan struct{}
}
func New(app *application.App) *Transcoder {
return &Transcoder{
app: app,
transcodeQueue: make(chan struct{}, app.Config().Transcoding.Parallel),
}
}
func (t *Transcoder) ConnectDependencies() error {
return nil
}
func (t *Transcoder) Start() error {
return nil
}