mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
initial Go port of streamrip
This commit is contained in:
123
internal/audio/convert/convert.go
Normal file
123
internal/audio/convert/convert.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
type profile struct {
|
||||
codecLib string
|
||||
ext string
|
||||
lossless bool
|
||||
}
|
||||
|
||||
var profiles = map[string]profile{
|
||||
"FLAC": {codecLib: "flac", ext: "flac", lossless: true},
|
||||
"ALAC": {codecLib: "alac", ext: "m4a", lossless: true},
|
||||
"OPUS": {codecLib: "libopus", ext: "opus", lossless: false},
|
||||
"MP3": {codecLib: "libmp3lame", ext: "mp3", lossless: false},
|
||||
"VORBIS": {codecLib: "libvorbis", ext: "ogg", lossless: false},
|
||||
"AAC": {codecLib: "aac", ext: "m4a", lossless: false},
|
||||
}
|
||||
|
||||
func Convert(path string, cfg config.ConversionConfig) (string, error) {
|
||||
if !cfg.Enabled {
|
||||
return path, nil
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return path, fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
|
||||
p, ok := profiles[strings.ToUpper(strings.TrimSpace(cfg.Codec))]
|
||||
if !ok {
|
||||
return path, fmt.Errorf("unsupported conversion codec: %s", cfg.Codec)
|
||||
}
|
||||
|
||||
base := strings.TrimSuffix(path, filepath.Ext(path))
|
||||
finalPath := base + "." + p.ext
|
||||
tmpPath := finalPath + ".tmp." + p.ext
|
||||
|
||||
args := buildFFmpegArgs(path, tmpPath, p, cfg)
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return path, fmt.Errorf("conversion failed: %w: %s", err, string(output))
|
||||
}
|
||||
|
||||
if path != finalPath {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
if err = os.Rename(tmpPath, finalPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return path, err
|
||||
}
|
||||
|
||||
return finalPath, nil
|
||||
}
|
||||
|
||||
func buildFFmpegArgs(inputPath, outputPath string, p profile, cfg config.ConversionConfig) []string {
|
||||
args := []string{
|
||||
"-y",
|
||||
"-i", inputPath,
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0",
|
||||
"-c:a", p.codecLib,
|
||||
}
|
||||
|
||||
if p.lossless {
|
||||
filter := buildLosslessFilter(cfg)
|
||||
if filter != "" {
|
||||
args = append(args, "-af", filter)
|
||||
}
|
||||
} else {
|
||||
if cfg.LossyBitrate > 0 {
|
||||
args = append(args, "-b:a", strconv.Itoa(cfg.LossyBitrate)+"k")
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, outputPath)
|
||||
return args
|
||||
}
|
||||
|
||||
func buildLosslessFilter(cfg config.ConversionConfig) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if cfg.SamplingRate > 0 {
|
||||
rates := allowedSampleRates(cfg.SamplingRate)
|
||||
if len(rates) > 0 {
|
||||
parts = append(parts, "sample_rates="+strings.Join(rates, "|"))
|
||||
}
|
||||
}
|
||||
if cfg.BitDepth == 16 {
|
||||
parts = append(parts, "sample_fmts=s16p|s16")
|
||||
} else if cfg.BitDepth == 24 || cfg.BitDepth == 32 {
|
||||
parts = append(parts, "sample_fmts=s16p|s16|s32p|s32")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "aformat=" + strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func allowedSampleRates(max int) []string {
|
||||
all := []int{44100, 48000, 88200, 96000, 176400, 192000}
|
||||
out := make([]int, 0, len(all))
|
||||
for _, r := range all {
|
||||
if r <= max {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
str := make([]string, 0, len(out))
|
||||
for _, r := range out {
|
||||
str = append(str, strconv.Itoa(r))
|
||||
}
|
||||
return str
|
||||
}
|
||||
43
internal/audio/convert/convert_test.go
Normal file
43
internal/audio/convert/convert_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
func TestAllowedSampleRates(t *testing.T) {
|
||||
got := allowedSampleRates(96000)
|
||||
want := []string{"44100", "48000", "88200", "96000"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("rates=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsLossless(t *testing.T) {
|
||||
cfg := config.ConversionConfig{Enabled: true, Codec: "FLAC", SamplingRate: 48000, BitDepth: 16}
|
||||
args := buildFFmpegArgs("in.flac", "out.flac", profiles["FLAC"], cfg)
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-c:a flac") {
|
||||
t.Fatalf("missing flac codec args=%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "sample_rates=44100|48000") {
|
||||
t.Fatalf("missing sample rate filter args=%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "sample_fmts=s16p|s16") {
|
||||
t.Fatalf("missing bit depth filter args=%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsLossy(t *testing.T) {
|
||||
cfg := config.ConversionConfig{Enabled: true, Codec: "MP3", LossyBitrate: 320}
|
||||
args := buildFFmpegArgs("in.flac", "out.mp3", profiles["MP3"], cfg)
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-c:a libmp3lame") {
|
||||
t.Fatalf("missing mp3 codec args=%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-b:a 320k") {
|
||||
t.Fatalf("missing bitrate args=%s", joined)
|
||||
}
|
||||
}
|
||||
169
internal/audio/tag/tagger.go
Normal file
169
internal/audio/tag/tagger.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Album string
|
||||
Artist string
|
||||
AlbumArtist string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
TrackTotal int
|
||||
DiscTotal int
|
||||
Date string
|
||||
Genre string
|
||||
Comment string
|
||||
Description string
|
||||
Lyrics string
|
||||
Copyright string
|
||||
ISRC string
|
||||
ReplaygainTrackGain string
|
||||
ReplaygainAlbumGain string
|
||||
SourcePlatform string
|
||||
SourceTrackID string
|
||||
SourceAlbumID string
|
||||
SourceArtistID string
|
||||
}
|
||||
|
||||
type Tagger struct{}
|
||||
|
||||
func New() *Tagger {
|
||||
return &Tagger{}
|
||||
}
|
||||
|
||||
func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := path + ".tmp.flac"
|
||||
args := buildFFmpegArgs(path, tmpPath, meta, coverPath)
|
||||
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("ffmpeg tag failed: %w: %s", err, string(output))
|
||||
}
|
||||
|
||||
if err = os.Rename(tmpPath, path); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath string) []string {
|
||||
args := []string{"-y", "-i", inputPath}
|
||||
withCover := coverPath != "" && fileExists(coverPath)
|
||||
if withCover {
|
||||
args = append(args, "-i", coverPath)
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-map", "0:a",
|
||||
"-c:a", "copy",
|
||||
)
|
||||
if withCover {
|
||||
args = append(args,
|
||||
"-map", "1:v:0",
|
||||
"-c:v", "mjpeg",
|
||||
"-disposition:v:0", "attached_pic",
|
||||
)
|
||||
}
|
||||
|
||||
for k, v := range toTags(meta) {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
args = append(args, "-metadata", k+"="+v)
|
||||
}
|
||||
|
||||
args = append(args, outputPath)
|
||||
return args
|
||||
}
|
||||
|
||||
func toTags(meta Metadata) map[string]string {
|
||||
tags := map[string]string{
|
||||
"title": meta.Title,
|
||||
"album": meta.Album,
|
||||
"artist": meta.Artist,
|
||||
"album_artist": meta.AlbumArtist,
|
||||
"date": meta.Date,
|
||||
"genre": meta.Genre,
|
||||
"comment": meta.Comment,
|
||||
"description": meta.Description,
|
||||
"lyrics": meta.Lyrics,
|
||||
"copyright": normalizeCopyright(meta.Copyright),
|
||||
"isrc": meta.ISRC,
|
||||
"replaygain_track_gain": meta.ReplaygainTrackGain,
|
||||
"replaygain_album_gain": meta.ReplaygainAlbumGain,
|
||||
"source_platform": strings.ToUpper(strings.TrimSpace(meta.SourcePlatform)),
|
||||
"source_track_id": meta.SourceTrackID,
|
||||
"source_album_id": meta.SourceAlbumID,
|
||||
"source_artist_id": meta.SourceArtistID,
|
||||
}
|
||||
if meta.TrackNumber > 0 {
|
||||
if meta.TrackTotal > 0 {
|
||||
tags["track"] = fmt.Sprintf("%02d/%02d", meta.TrackNumber, meta.TrackTotal)
|
||||
} else {
|
||||
tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
|
||||
}
|
||||
}
|
||||
if meta.TrackTotal > 0 {
|
||||
tags["tracktotal"] = strconv.Itoa(meta.TrackTotal)
|
||||
}
|
||||
if meta.DiscNumber > 0 {
|
||||
if meta.DiscTotal > 0 {
|
||||
tags["disc"] = fmt.Sprintf("%d/%d", meta.DiscNumber, meta.DiscTotal)
|
||||
} else {
|
||||
tags["disc"] = strconv.Itoa(meta.DiscNumber)
|
||||
}
|
||||
}
|
||||
if meta.DiscTotal > 0 {
|
||||
tags["disctotal"] = strconv.Itoa(meta.DiscTotal)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func normalizeCopyright(in string) string {
|
||||
out := strings.ReplaceAll(in, "(c)", "©")
|
||||
out = strings.ReplaceAll(out, "(C)", "©")
|
||||
out = strings.ReplaceAll(out, "(p)", "℗")
|
||||
out = strings.ReplaceAll(out, "(P)", "℗")
|
||||
return out
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !st.IsDir()
|
||||
}
|
||||
|
||||
func CoverPathForTrack(trackPath string, albumFolder string) string {
|
||||
if albumFolder != "" {
|
||||
p := filepath.Join(albumFolder, "cover.jpg")
|
||||
if fileExists(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
p := filepath.Join(filepath.Dir(trackPath), "cover.jpg")
|
||||
if fileExists(p) {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
73
internal/audio/tag/tagger_test.go
Normal file
73
internal/audio/tag/tagger_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeCopyright(t *testing.T) {
|
||||
got := normalizeCopyright("(c) test (P) other")
|
||||
if got != "© test ℗ other" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTagsTrackDiscFormatting(t *testing.T) {
|
||||
tags := toTags(Metadata{TrackNumber: 3, DiscNumber: 2})
|
||||
if tags["track"] != "03" {
|
||||
t.Fatalf("track tag = %q", tags["track"])
|
||||
}
|
||||
if tags["disc"] != "2" {
|
||||
t.Fatalf("disc tag = %q", tags["disc"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
||||
tags := toTags(Metadata{
|
||||
TrackNumber: 3,
|
||||
TrackTotal: 12,
|
||||
DiscNumber: 1,
|
||||
DiscTotal: 2,
|
||||
ISRC: "USABC1234567",
|
||||
SourcePlatform: "qobuz",
|
||||
SourceTrackID: "t1",
|
||||
})
|
||||
if tags["track"] != "03/12" {
|
||||
t.Fatalf("track tag = %q", tags["track"])
|
||||
}
|
||||
if tags["disc"] != "1/2" {
|
||||
t.Fatalf("disc tag = %q", tags["disc"])
|
||||
}
|
||||
if tags["tracktotal"] != "12" || tags["disctotal"] != "2" {
|
||||
t.Fatalf("totals missing: %+v", tags)
|
||||
}
|
||||
if tags["isrc"] != "USABC1234567" {
|
||||
t.Fatalf("isrc missing: %+v", tags)
|
||||
}
|
||||
if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" {
|
||||
t.Fatalf("source tags missing: %+v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsWithCover(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cover := filepath.Join(tmp, "cover.jpg")
|
||||
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write cover: %v", err)
|
||||
}
|
||||
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover)
|
||||
foundInput2 := false
|
||||
foundAttach := false
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "-i" && args[i+1] == cover {
|
||||
foundInput2 = true
|
||||
}
|
||||
if args[i] == "-disposition:v:0" && args[i+1] == "attached_pic" {
|
||||
foundAttach = true
|
||||
}
|
||||
}
|
||||
if !foundInput2 || !foundAttach {
|
||||
t.Fatalf("missing cover args: %v", args)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user