initial commit
This commit is contained in:
commit
1897aa3153
68
bool_func_utils.go
Normal file
68
bool_func_utils.go
Normal file
@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func AbsPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
|
||||
absSource, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return absSource
|
||||
}
|
||||
|
||||
func Exists(path string) bool {
|
||||
_, err := os.Lstat(path)
|
||||
return !errors.Is(err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
func SymlinkFilesOnly(path string) bool {
|
||||
stat, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
isSymlink := stat.Mode().Type()&os.ModeSymlink == os.ModeSymlink
|
||||
return isSymlink && !stat.IsDir()
|
||||
}
|
||||
|
||||
func VideosOnly(path string) bool {
|
||||
streams, err := GetStreamsFromFile(path)
|
||||
return err == nil && strings.Contains(streams, "video")
|
||||
}
|
||||
|
||||
func Not[T any](f func(T) bool) func(T) bool {
|
||||
return func(t T) bool { return !f(t) }
|
||||
}
|
||||
|
||||
func And[T any](functions ...func(T) bool) func(T) bool {
|
||||
return func(t T) bool {
|
||||
for _, f := range functions {
|
||||
if !f(t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func Or[T any](functions ...func(T) bool) func(T) bool {
|
||||
return func(t T) bool {
|
||||
for _, f := range functions {
|
||||
if f(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
41
ffmpeg.go
Normal file
41
ffmpeg.go
Normal file
@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func GetStreamsFromFile(path string) (string, error) {
|
||||
cmd := exec.Command(
|
||||
"ffprobe",
|
||||
"-loglevel", "error",
|
||||
"-show_entries", "stream=codec_type",
|
||||
"-of", "csv=p=0",
|
||||
path,
|
||||
)
|
||||
|
||||
data, err := cmd.Output()
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func ConvertToAudio(ctx context.Context, videoFile, targetPath string) error {
|
||||
cmd := exec.CommandContext(
|
||||
ctx, "ffmpeg",
|
||||
"-i", videoFile,
|
||||
"-y", // overwrite existing file
|
||||
"-vn", // no video
|
||||
"-ar", "44100", // sample rate
|
||||
"-ac", "2", // audio channels
|
||||
"-ab", "192k", // dont know dont care
|
||||
"-f", "mp3", // output format
|
||||
targetPath,
|
||||
)
|
||||
|
||||
data, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during process execution: %w\nOutput:\n%s", err, string(data))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
14
go.mod
Normal file
14
go.mod
Normal file
@ -0,0 +1,14 @@
|
||||
module git.tordarus.net/tordarus/music-library
|
||||
|
||||
go 1.23
|
||||
|
||||
toolchain go1.23.4
|
||||
|
||||
require (
|
||||
git.tordarus.net/tordarus/channel v0.1.18
|
||||
git.tordarus.net/tordarus/cmap v0.0.4
|
||||
git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.20.0 // indirect
|
10
go.sum
Normal file
10
go.sum
Normal file
@ -0,0 +1,10 @@
|
||||
git.tordarus.net/tordarus/channel v0.1.18 h1:/9BDbkyXbVpFB+dQbToniX6g/ApBnzjslYt4NiycMQo=
|
||||
git.tordarus.net/tordarus/channel v0.1.18/go.mod h1:8/dWFTdGO7g4AeSZ7cF6GerkGbe9c4dBVMVDBxOd9m4=
|
||||
git.tordarus.net/tordarus/cmap v0.0.4 h1:w4J6wDrfM9xhmbMdLcLh9Ilsl6VjbDfMtn43Vux9u7Y=
|
||||
git.tordarus.net/tordarus/cmap v0.0.4/go.mod h1:1durIqqr4b2/J0EcM1k2VpzAQHAqJzAWfRm7qU0fcPw=
|
||||
git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5 h1:rKNDX/YGunqg8TEU6q1rgS2BcDKVmUW2cg61JOE/wws=
|
||||
git.tordarus.net/tordarus/envvars v0.0.0-20250114175450-d73e12b838a5/go.mod h1:/qVGwrEmqtIrZyuuoIQl4vquSkPWUNJmlGNedDrdYfg=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
64
hash_cache.go
Normal file
64
hash_cache.go
Normal file
@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.tordarus.net/tordarus/cmap"
|
||||
)
|
||||
|
||||
var HashCache = cmap.New[[sha512.Size]byte, [sha512.Size]byte]()
|
||||
|
||||
func InitCache() {
|
||||
path := os.ExpandEnv(FlagCacheFile)
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
data := map[[sha512.Size]byte][sha512.Size]byte{}
|
||||
gob.NewDecoder(file).Decode(&data)
|
||||
HashCache.Do(func(m map[[64]byte][64]byte) {
|
||||
for key, value := range data {
|
||||
m[key] = value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := WriteCache(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, fmt.Errorf("could not write cache file: %w", err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func WriteCache() error {
|
||||
path := os.ExpandEnv(FlagCacheFile)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data := HashCache.Clone()
|
||||
|
||||
if err := gob.NewEncoder(file).Encode(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("hashsum cache written")
|
||||
|
||||
return nil
|
||||
}
|
118
main.go
Normal file
118
main.go
Normal file
@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.tordarus.net/tordarus/channel"
|
||||
"git.tordarus.net/tordarus/envvars"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
var ( // flags
|
||||
FlagSourceDir = envvars.String("SOURCE_PATH", "/video")
|
||||
FlagTargetDir = envvars.String("TARGET_PATH", "/audio")
|
||||
FlagCacheFile = envvars.String("CACHE_FILE", "/hashes.bin")
|
||||
)
|
||||
|
||||
func main() {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
fmt.Println("ffmpeg not found in PATH")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
FlagSourceDir = AbsPath(FlagSourceDir)
|
||||
FlagTargetDir = AbsPath(FlagTargetDir)
|
||||
FlagCacheFile = AbsPath(FlagCacheFile)
|
||||
|
||||
InitCache()
|
||||
|
||||
ops := []fsnotify.Op{
|
||||
fsnotify.Create,
|
||||
fsnotify.Remove,
|
||||
fsnotify.Write,
|
||||
fsnotify.Rename,
|
||||
}
|
||||
|
||||
files, err := WatchDirectory(context.Background(), FlagSourceDir, ops...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
groupedFiles := channel.GroupByTime(files, time.Second, func(events map[string]struct{}, event string) map[string]struct{} {
|
||||
if events == nil {
|
||||
events = map[string]struct{}{}
|
||||
}
|
||||
events[event] = struct{}{}
|
||||
return events
|
||||
})
|
||||
|
||||
filesByGroup := channel.FlatMap(groupedFiles, func(key string, _ struct{}) string { return key })
|
||||
filteredFiles := channel.Filter(filesByGroup, Or(Not(Exists), And(SymlinkFilesOnly, VideosOnly)))
|
||||
|
||||
for file := range filteredFiles {
|
||||
if err := TranscodeFile(file); err != nil {
|
||||
fmt.Fprintln(os.Stderr, fmt.Errorf("could not transcode file '%s': %w", file, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TranscodeFile(file string) error {
|
||||
relPath := strings.TrimPrefix(file, FlagSourceDir)
|
||||
newPath := filepath.Join(FlagTargetDir, relPath)
|
||||
newDir := filepath.Dir(newPath)
|
||||
|
||||
fmt.Printf("check '%s'\n", relPath)
|
||||
|
||||
// file was deleted in source directory.
|
||||
// delete corresponding file in target directory as well
|
||||
if _, err := os.Stat(file); errors.Is(err, fs.ErrNotExist) {
|
||||
fmt.Printf("remove '%s'\n", relPath)
|
||||
return os.Remove(newPath)
|
||||
}
|
||||
|
||||
oldFileHash, err := HashFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(newDir, 0755); err != nil {
|
||||
return fmt.Errorf("creation of playlist failed: %w", err)
|
||||
}
|
||||
|
||||
// file does exist in target directory.
|
||||
// compare hashsums to determine if file should be re-transcoded
|
||||
if _, err := os.Stat(newPath); !errors.Is(err, fs.ErrNotExist) {
|
||||
if expectedFileHash, exists := HashCache.GetHas(oldFileHash); exists {
|
||||
currentFileHash, err := HashFile(newPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentFileHash == expectedFileHash {
|
||||
fmt.Printf("skip '%s'. hashes match\n", relPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("convert '%s'\n", relPath)
|
||||
if err := ConvertToAudio(context.Background(), file, newPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, fmt.Errorf("encoding of video '%s' failed: %w", file, err))
|
||||
}
|
||||
|
||||
newFileHash, err := HashFile(newPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
HashCache.Put(oldFileHash, newFileHash)
|
||||
return nil
|
||||
}
|
25
utils.go
Normal file
25
utils.go
Normal file
@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func HashFile(path string) ([sha512.Size]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return [sha512.Size]byte{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha512.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return [sha512.Size]byte{}, err
|
||||
}
|
||||
|
||||
res := [sha512.Size]byte{}
|
||||
hash := hasher.Sum(nil)
|
||||
copy(res[:], hash)
|
||||
return res, nil
|
||||
}
|
100
watch_dir.go
Normal file
100
watch_dir.go
Normal file
@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
func WatchDirectory(ctx context.Context, path string, op ...fsnotify.Op) (<-chan string, error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ops := fsnotify.Op(0)
|
||||
for _, op := range op {
|
||||
ops |= op
|
||||
}
|
||||
|
||||
err = watcher.Add(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan string, 1000)
|
||||
|
||||
// close channel on app shutdown
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
watcher.Close()
|
||||
}()
|
||||
|
||||
go func(watcher *fsnotify.Watcher, ch chan<- string) {
|
||||
defer watcher.Close()
|
||||
defer close(ch)
|
||||
|
||||
for _, file := range files {
|
||||
path := filepath.Join(path, file.Name())
|
||||
if file.IsDir() {
|
||||
//fmt.Println("watching directory", path)
|
||||
watcher.Add(path)
|
||||
|
||||
if dirFiles, err := os.ReadDir(path); err == nil {
|
||||
for _, dirFile := range dirFiles {
|
||||
ch <- filepath.Join(path, dirFile.Name())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ch <- path
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
|
||||
if fi, err := os.Stat(event.Name); err == nil && fi.IsDir() {
|
||||
if event.Op&fsnotify.Create == fsnotify.Create {
|
||||
//fmt.Println("watching directory", event.Name)
|
||||
watcher.Add(event.Name)
|
||||
}
|
||||
|
||||
// read dir immediately because directory files could change simultanously with its parent directory
|
||||
if dirFiles, err := os.ReadDir(event.Name); err == nil {
|
||||
for _, dirFile := range dirFiles {
|
||||
ch <- filepath.Join(event.Name, dirFile.Name())
|
||||
}
|
||||
}
|
||||
} else if err != nil && event.Op&fsnotify.Remove == fsnotify.Remove {
|
||||
//fmt.Println("stopped watching directory", event.Name)
|
||||
watcher.Remove(event.Name)
|
||||
}
|
||||
|
||||
if event.Op&ops > 0 {
|
||||
ch <- event.Name
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if ok {
|
||||
fmt.Println(err, ok)
|
||||
}
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(watcher, ch)
|
||||
|
||||
return ch, nil
|
||||
}
|
Loading…
Reference in New Issue
Block a user