Initial commit
Proof-of-concept implementation. Bugs will occur.
This commit is contained in:
10
internal/domains/cacher.go
Normal file
10
internal/domains/cacher.go
Normal 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)
|
||||
}
|
||||
56
internal/domains/cacher/cacher.go
Normal file
56
internal/domains/cacher/cacher.go
Normal 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
|
||||
}
|
||||
37
internal/domains/cacher/cleanup.go
Normal file
37
internal/domains/cacher/cleanup.go
Normal 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
|
||||
}
|
||||
9
internal/domains/cacher/dto/cache_item.go
Normal file
9
internal/domains/cacher/dto/cache_item.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type CacheItem struct {
|
||||
Path string
|
||||
Size int64
|
||||
Updated time.Time
|
||||
}
|
||||
11
internal/domains/cacher/errors.go
Normal file
11
internal/domains/cacher/errors.go
Normal 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")
|
||||
)
|
||||
98
internal/domains/cacher/files.go
Normal file
98
internal/domains/cacher/files.go
Normal 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
|
||||
}
|
||||
21
internal/domains/cacher/models/cache_item.go
Normal file
21
internal/domains/cacher/models/cache_item.go
Normal 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,
|
||||
}
|
||||
}
|
||||
9
internal/domains/cacher/models/cacher_stat.go
Normal file
9
internal/domains/cacher/models/cacher_stat.go
Normal 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
|
||||
}
|
||||
64
internal/domains/cacher/stats.go
Normal file
64
internal/domains/cacher/stats.go
Normal 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
|
||||
}
|
||||
6
internal/domains/domain.go
Normal file
6
internal/domains/domain.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package domains
|
||||
|
||||
type Domain interface {
|
||||
ConnectDependencies() error
|
||||
Start() error
|
||||
}
|
||||
5
internal/domains/filesystem.go
Normal file
5
internal/domains/filesystem.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package domains
|
||||
|
||||
const FilesystemName = "filesystem"
|
||||
|
||||
type Filesystem interface{}
|
||||
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,
|
||||
}
|
||||
}
|
||||
8
internal/domains/transcoder.go
Normal file
8
internal/domains/transcoder.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package domains
|
||||
|
||||
const TranscoderName = "transcoder"
|
||||
|
||||
type Transcoder interface {
|
||||
Convert(sourcePath, destinationPath string) (int64, error)
|
||||
QueueChannel() chan struct{}
|
||||
}
|
||||
40
internal/domains/transcoder/album_art.go
Normal file
40
internal/domains/transcoder/album_art.go
Normal 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 ""
|
||||
}
|
||||
212
internal/domains/transcoder/convert.go
Normal file
212
internal/domains/transcoder/convert.go
Normal 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
|
||||
}
|
||||
10
internal/domains/transcoder/errors.go
Normal file
10
internal/domains/transcoder/errors.go
Normal 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")
|
||||
)
|
||||
41
internal/domains/transcoder/metadata.go
Normal file
41
internal/domains/transcoder/metadata.go
Normal 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"
|
||||
}
|
||||
5
internal/domains/transcoder/queue.go
Normal file
5
internal/domains/transcoder/queue.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package transcoder
|
||||
|
||||
func (t *Transcoder) QueueChannel() chan struct{} {
|
||||
return t.transcodeQueue
|
||||
}
|
||||
31
internal/domains/transcoder/transcoder.go
Normal file
31
internal/domains/transcoder/transcoder.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user