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