initial Go port of streamrip

This commit is contained in:
2026-04-19 21:11:38 +02:00
commit 97e8b758b3
32 changed files with 7008 additions and 0 deletions

View 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
}

View 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)
}
}

View 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 ""
}

View 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)
}
}