initial commit
This commit is contained in:
		
							
								
								
									
										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 | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user