Merge branch 'master' into fork/customizable-ratings-field

This commit is contained in:
Deluan 2024-04-13 12:12:35 -04:00
commit 4714d85a56
477 changed files with 19932 additions and 5932 deletions

View File

@ -2,7 +2,7 @@
# [Choice] Go version: 1, 1.15, 1.14
ARG VARIANT="1"
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT}
# [Option] Install Node.js
ARG INSTALL_NODE="true"
@ -17,4 +17,4 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# RUN go get -x <your-dependency-or-tool>
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View File

@ -4,10 +4,10 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.21",
"VARIANT": "1.22",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v18"
"NODE_VERSION": "v20"
}
},
"workspaceMount": "",

View File

@ -1,4 +1,4 @@
name: 'Pipeline: Test, Lint, Build'
name: "Pipeline: Test, Lint, Build"
on:
push:
branches:
@ -8,23 +8,20 @@ on:
pull_request:
branches:
- master
jobs:
go-lint:
name: Lint Go code
runs-on: ubuntu-latest
container: deluan/ci-goreleaser:1.22.2-1
steps:
- name: Install taglib
run: sudo apt-get install libtag1-dev
- uses: actions/checkout@v4
- name: Set up Go 1.21
uses: actions/setup-go@v3
with:
go-version: 1.21.x
- uses: actions/checkout@v3
- name: Config workspace folder as trusted
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v4
with:
version: latest
github-token: ${{ secrets.GITHUB_TOKEN }}
@ -44,23 +41,15 @@ jobs:
fi
go:
name: Test with Go ${{ matrix.go_version }}
name: Test Go code
runs-on: ubuntu-latest
strategy:
matrix:
go_version: [1.21.x,1.20.x]
container: deluan/ci-goreleaser:1.22.2-1
steps:
- name: Install taglib
run: sudo apt-get install libtag1-dev
- name: Check out code into the Go module directory
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Go ${{ matrix.go_version }}
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go_version }}
cache: true
- name: Config workspace folder as trusted
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
- name: Download dependencies
if: steps.cache-go.outputs.cache-hit != 'true'
@ -75,14 +64,14 @@ jobs:
name: Build JS bundle
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max_old_space_size=4096'
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: 20
cache: "npm"
cache-dependency-path: "**/package-lock.json"
- name: npm install dependencies
run: |
@ -104,7 +93,7 @@ jobs:
cd ui
npm run build
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: js-bundle
path: ui/build
@ -114,41 +103,34 @@ jobs:
name: Build binaries
needs: [js, go, go-lint]
runs-on: ubuntu-latest
container: deluan/ci-goreleaser:1.22.2-1
steps:
- name: Checkout Code
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v3
- name: Config workspace folder as trusted
run: git config --global --add safe.directory $GITHUB_WORKSPACE; git describe --dirty --always --tags
- uses: actions/download-artifact@v4
with:
name: js-bundle
path: ui/build
- name: Config /github/workspace folder as trusted
uses: docker://deluan/ci-goreleaser:1.21.0-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: /bin/bash -c "git config --global --add safe.directory /github/workspace; git describe --dirty --always --tags"
- name: Run GoReleaser - SNAPSHOT
if: startsWith(github.ref, 'refs/tags/') != true
uses: docker://deluan/ci-goreleaser:1.21.0-1
run: goreleaser release --clean --skip=publish --snapshot
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist --skip-publish --snapshot
- name: Run GoReleaser - RELEASE
if: startsWith(github.ref, 'refs/tags/')
uses: docker://deluan/ci-goreleaser:1.21.0-1
run: goreleaser release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: binaries
path: |
@ -166,18 +148,18 @@ jobs:
steps:
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
if: env.DOCKER_IMAGE != ''
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
if: env.DOCKER_IMAGE != ''
- uses: actions/checkout@v3
- uses: actions/checkout@v4
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
if: env.DOCKER_IMAGE != ''
with:
name: binaries
@ -185,14 +167,14 @@ jobs:
- name: Login to Docker Hub
if: env.DOCKER_IMAGE != ''
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.DOCKER_IMAGE != ''
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -201,12 +183,12 @@ jobs:
- name: Extract metadata for Docker
if: env.DOCKER_IMAGE != ''
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
labels: |
maintainer=deluan
images: |
name=${{secrets.DOCKER_IMAGE}},enable=${{env.GITHUB_REF_TYPE == 'tag' || github.ref == format('refs/heads/{0}', 'master')}}
name=${{secrets.DOCKER_IMAGE}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr
@ -215,7 +197,7 @@ jobs:
- name: Build and Push
if: env.DOCKER_IMAGE != ''
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
file: .github/workflows/pipeline.dockerfile

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Get updated translations
env:
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
@ -20,7 +20,7 @@ jobs:
git status --porcelain
git diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@v4
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.PAT }}
commit-message: Update translations

3
.gitignore vendored
View File

@ -24,4 +24,5 @@ navidrome.db-wal
tags
.gitinfo
docker-compose.yml
!contrib/docker-compose.yml
!contrib/docker-compose.yml
test-123.db

2
.nvmrc
View File

@ -1 +1 @@
v18
v20

View File

@ -9,7 +9,7 @@ GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
endif
CI_RELEASER_VERSION=1.21.0-1 ## https://github.com/navidrome/ci-goreleaser
CI_RELEASER_VERSION=1.22.2-1 ## https://github.com/navidrome/ci-goreleaser
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@ -47,7 +47,7 @@ lintall: lint ##@Development Lint Go and JS code
format: ##@Development Format code
@(cd ./ui && npm run prettier)
@go run golang.org/x/tools/cmd/goimports -w `find . -name '*.go' | grep -v _gen.go$$`
@go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v _gen.go$$`
@go mod tidy
.PHONY: format
@ -85,13 +85,18 @@ build: warning-noui-build check_go_env ##@Build Build only backend
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
.PHONY: build
debug-build: warning-noui-build check_go_env ##@Build Build only backend (with remote debug on)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
.PHONY: debug-build
buildjs: check_node_env ##@Build Build only frontend
@(cd ./ui && npm run build)
.PHONY: buildjs
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
@echo "Building binaries for all platforms using builder ${CI_RELEASER_VERSION}"
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser release --rm-dist --skip-publish --snapshot
goreleaser release --clean --skip=publish --snapshot
.PHONY: all
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
@ -101,9 +106,9 @@ single: warning-noui-build ##@Cross_Compilation Build binaries for a single supp
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
exit 1; \
fi
@echo "Building binaries for ${GOOS}/${GOARCH}"
@echo "Building binaries for ${GOOS}/${GOARCH} using builder ${CI_RELEASER_VERSION}"
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
goreleaser build --clean --snapshot -p 2 --single-target --id navidrome_${GOOS}_${GOARCH}
.PHONY: single
warning-noui-build:

View File

@ -7,7 +7,7 @@
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Subreddit](https://img.shields.io/badge/%2Fr%2Fnavidrome-%2B3000-red?logo=reddit)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your

99
cmd/inspect.go Normal file
View File

@ -0,0 +1,99 @@
package cmd
import (
"encoding/json"
"fmt"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scanner/metadata"
"github.com/navidrome/navidrome/tests"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
var (
extractor string
format string
)
func init() {
inspectCmd.Flags().StringVarP(&extractor, "extractor", "x", "", "extractor to use (ffmpeg or taglib, default: auto)")
inspectCmd.Flags().StringVarP(&format, "format", "f", "pretty", "output format (pretty, toml, yaml, json, jsonindent)")
rootCmd.AddCommand(inspectCmd)
}
var inspectCmd = &cobra.Command{
Use: "inspect [files to inspect]",
Short: "Inspect tags",
Long: "Show file tags as seen by Navidrome",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runInspector(args)
},
}
var marshalers = map[string]func(interface{}) ([]byte, error){
"pretty": prettyMarshal,
"toml": toml.Marshal,
"yaml": yaml.Marshal,
"json": json.Marshal,
"jsonindent": func(v interface{}) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
},
}
func prettyMarshal(v interface{}) ([]byte, error) {
out := v.([]inspectorOutput)
var res strings.Builder
for i := range out {
res.WriteString(fmt.Sprintf("====================\nFile: %s\n\n", out[i].File))
t, _ := toml.Marshal(out[i].RawTags)
res.WriteString(fmt.Sprintf("Raw tags:\n%s\n\n", t))
t, _ = toml.Marshal(out[i].MappedTags)
res.WriteString(fmt.Sprintf("Mapped tags:\n%s\n\n", t))
}
return []byte(res.String()), nil
}
type inspectorOutput struct {
File string
RawTags metadata.ParsedTags
MappedTags model.MediaFile
}
func runInspector(args []string) {
if extractor != "" {
conf.Server.Scanner.Extractor = extractor
}
log.Info("Using extractor", "extractor", conf.Server.Scanner.Extractor)
md, err := metadata.Extract(args...)
if err != nil {
log.Fatal("Error extracting tags", err)
}
mapper := scanner.NewMediaFileMapper(conf.Server.MusicFolder, &tests.MockedGenreRepo{})
marshal := marshalers[format]
if marshal == nil {
log.Fatal("Invalid format", "format", format)
}
var out []inspectorOutput
for k, v := range md {
if !model.IsAudioFile(k) {
continue
}
if len(v.Tags) == 0 {
continue
}
out = append(out, inspectorOutput{
File: k,
RawTags: v.Tags,
MappedTags: mapper.ToMediaFile(v),
})
}
data, _ := marshal(out)
fmt.Println(string(data))
}

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
@ -75,6 +76,10 @@ func runNavidrome() {
g.Go(startScheduler(ctx))
g.Go(schedulePeriodicScan(ctx))
if conf.Server.Jukebox.Enabled {
g.Go(startPlaybackServer(ctx))
}
if err := g.Wait(); err != nil && !errors.Is(err, interrupted) {
log.Error("Fatal error in Navidrome. Aborting", err)
}
@ -146,6 +151,16 @@ func startScheduler(ctx context.Context) func() error {
}
}
func startPlaybackServer(ctx context.Context) func() error {
log.Info(ctx, "Starting playback server")
playbackInstance := playback.GetInstance()
return func() error {
return playbackInstance.Run(ctx)
}
}
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
@ -168,6 +183,7 @@ func init() {
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will listen to")
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL to configure Navidrome behind a proxy (ex: /music or http://my.server.com)")
rootCmd.Flags().String("tlscert", viper.GetString("tlscert"), "optional path to a TLS cert file (enables HTTPS listening)")
rootCmd.Flags().String("unixsocketperm", viper.GetString("unixsocketperm"), "optional file permission for the unix socket")
rootCmd.Flags().String("tlskey", viper.GetString("tlskey"), "optional path to a TLS key file (enables HTTPS listening)")
rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions")
@ -184,6 +200,7 @@ func init() {
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("tlscert", rootCmd.Flags().Lookup("tlscert"))
_ = viper.BindPFlag("unixsocketperm", rootCmd.Flags().Lookup("unixsocketperm"))
_ = viper.BindPFlag("tlskey", rootCmd.Flags().Lookup("tlskey"))
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))

View File

@ -1,6 +1,6 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run github.com/google/wire/cmd/wire
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
@ -40,7 +40,8 @@ func CreateNativeAPIRouter() *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
router := nativeapi.New(dataStore, share)
playlists := core.NewPlaylists(dataStore)
router := nativeapi.New(dataStore, share, playlists)
return router
}

View File

@ -12,7 +12,6 @@ import (
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/number"
"github.com/robfig/cron/v3"
"github.com/spf13/viper"
)
@ -21,6 +20,7 @@ type configOptions struct {
ConfigFile string
Address string
Port int
UnixSocketPerm string
MusicFolder string
DataFolder string
CacheFolder string
@ -51,10 +51,12 @@ type configOptions struct {
DefaultDownsamplingFormat string
SearchFullString bool
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
IndexGroups string
SubsonicArtistParticipations bool
FFmpegPath string
MPVPath string
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
@ -78,6 +80,7 @@ type configOptions struct {
ReverseProxyWhitelist string
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
Agents string
LastFM lastfmOptions
@ -94,6 +97,7 @@ type configOptions struct {
DevSidebarPlaylists bool
DevEnableBufferedScrobble bool
DevShowArtistPage bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
@ -129,6 +133,14 @@ type prometheusOptions struct {
MetricsPath string
}
type AudioDeviceDefinition []string
type jukeboxOptions struct {
Enabled bool
Devices []AudioDeviceDefinition
Default string
}
var (
Server = &configOptions{}
hooks []func()
@ -193,7 +205,7 @@ func Load() {
}
// Print current configuration if log level is Debug
if log.CurrentLevel() >= log.LevelDebug {
if log.IsGreaterOrEqualTo(log.LevelDebug) {
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
@ -263,6 +275,7 @@ func init() {
viper.SetDefault("loglevel", "info")
viper.SetDefault("address", "0.0.0.0")
viper.SetDefault("port", 4533)
viper.SetDefault("unixsocketperm", "0660")
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("scaninterval", -1)
viper.SetDefault("scanschedule", "@every 1m")
@ -285,6 +298,7 @@ func init() {
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("searchfullstring", false)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("subsonicartistparticipations", false)
@ -313,6 +327,10 @@ func init() {
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", "/metrics")
viper.SetDefault("jukebox.enabled", false)
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
viper.SetDefault("jukebox.default", "")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.genreseparators", ";/,")
viper.SetDefault("scanner.groupalbumreleases", false)
@ -320,8 +338,8 @@ func init() {
viper.SetDefault("agents", "lastfm,spotify")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en")
viper.SetDefault("lastfm.apikey", consts.LastFMAPIKey)
viper.SetDefault("lastfm.secret", consts.LastFMAPISecret)
viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "")
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("listenbrainz.enabled", true)
@ -338,7 +356,8 @@ func init() {
viper.SetDefault("devenablebufferedscrobble", true)
viper.SetDefault("devsidebarplaylists", true)
viper.SetDefault("devshowartistpage", true)
viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()/3))
viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)

View File

@ -81,12 +81,6 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// Shared secrets (only add here "secrets" that can be public)
const (
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
)
var (
DefaultDownsamplingFormat = "opus"
DefaultTranscodings = []map[string]interface{}{
@ -94,19 +88,19 @@ var (
"name": "mp3 audio",
"targetFormat": "mp3",
"defaultBitRate": 192,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
"command": "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
"name": "opus audio",
"targetFormat": "opus",
"defaultBitRate": 128,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
"command": "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
"name": "aac audio",
"targetFormat": "aac",
"defaultBitRate": 256,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
"command": "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
}

View File

@ -33,6 +33,7 @@ var audioFormats = map[string]format{
".dsf": {typ: "audio/dsd", lossless: true},
".wv": {typ: "audio/x-wavpack", lossless: true},
".wvp": {typ: "audio/x-wavpack", lossless: true},
".tak": {typ: "audio/tak", lossless: true},
".mka": {typ: "audio/x-matroska"},
}
var imageFormats = map[string]string{

View File

@ -134,7 +134,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
}
similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit)
if len(similar) > 0 && err == nil {
if log.CurrentLevel() >= log.LevelTrace {
if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
} else {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start))

View File

@ -311,12 +311,14 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
func init() {
conf.AddHook(func() {
if conf.Server.LastFM.Enabled {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
return lastFMConstructor(ds)
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return lastFMConstructor(ds)
})
if conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
return lastFMConstructor(ds)
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return lastFMConstructor(ds)
})
}
}
})
}

View File

@ -18,7 +18,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/req"
)
//go:embed token_received.html
@ -89,13 +89,14 @@ func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
}
func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
token := utils.ParamString(r, "token")
if token == "" {
p := req.Params(r)
token, err := p.String("token")
if err != nil {
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
return
}
uid := utils.ParamString(r, "uid")
if uid == "" {
uid, err := p.String("uid")
if err != nil {
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
return
}
@ -103,7 +104,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
// automatically contain any user info
ctx := request.WithUser(r.Context(), model.User{ID: uid})
err := s.fetchSessionKey(ctx, uid, token)
err = s.fetchSessionKey(ctx, uid, token)
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)

View File

@ -8,13 +8,13 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/log"
"golang.org/x/exp/slices"
)
const (

View File

@ -150,7 +150,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
var r io.ReadCloser
if format != "raw" && format != "" {
r, err = a.ms.DoStream(ctx, &mf, format, bitrate)
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
} else {
r, err = os.Open(mf.Path)
}
@ -160,7 +160,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
}
defer func() {
if err := r.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", mf.Path, err)
}
}()

View File

@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() {
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@ -192,8 +192,8 @@ type mockMediaStreamer struct {
core.MediaStreamer
}
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, format string, bitrate int) (*core.Stream, error) {
args := m.Called(ctx, mf, format, bitrate)
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
if args.Error(1) != nil {
return nil, args.Error(1)
}

View File

@ -131,7 +131,7 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize)
if err != nil {
return fmt.Errorf("error cacheing id='%s': %w", id, err)
return fmt.Errorf("error caching id='%s': %w", id, err)
}
defer r.Close()
_, err = io.Copy(io.Discard, r)

View File

@ -16,7 +16,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/number"
)
type resizedArtworkReader struct {
@ -113,7 +112,7 @@ func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
// Don't upscale the image
bounds := img.Bounds()
originalSize := number.Max(bounds.Max.X, bounds.Max.Y)
originalSize := max(bounds.Max.X, bounds.Max.Y)
if originalSize <= size {
return nil, originalSize, nil
}

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@ -23,9 +24,10 @@ var (
func Init(ds model.DataStore) {
once.Do(func() {
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
secret, err := ds.Property(context.TODO()).DefaultGet(consts.JWTSecretKey, "not so secret")
if err != nil {
secret, err := ds.Property(context.TODO()).Get(consts.JWTSecretKey)
if err != nil || secret == "" {
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
secret = uuid.NewString()
}
Secret = []byte(secret)
TokenAuth = jwtauth.New("HS256", Secret, nil)

View File

@ -18,7 +18,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/number"
. "github.com/navidrome/navidrome/utils/gg"
"golang.org/x/sync/errgroup"
)
@ -90,15 +90,16 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
return nil, err
}
if album.ExternalInfoUpdatedAt.IsZero() {
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name)
updatedAt := V(album.ExternalInfoUpdatedAt)
if updatedAt.IsZero() {
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
err = e.populateAlbumInfo(ctx, album)
if err != nil {
return nil, err
}
}
if time.Since(album.ExternalInfoUpdatedAt) > conf.Server.DevAlbumInfoTimeToLive {
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
enqueueRefresh(e.albumQueue, album)
}
@ -118,7 +119,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbu
return err
}
album.ExternalInfoUpdatedAt = time.Now()
album.ExternalInfoUpdatedAt = P(time.Now())
album.ExternalUrl = info.URL
if info.Description != "" {
@ -202,8 +203,9 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*a
}
// If we don't have any info, retrieves it now
if artist.ExternalInfoUpdatedAt.IsZero() {
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
updatedAt := V(artist.ExternalInfoUpdatedAt)
if updatedAt.IsZero() {
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
err := e.populateArtistInfo(ctx, artist)
if err != nil {
return nil, err
@ -211,8 +213,8 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*a
}
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(artist.ExternalInfoUpdatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
enqueueRefresh(e.artistQueue, artist)
}
return artist, nil
@ -242,7 +244,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxAr
return ctx.Err()
}
artist.ExternalInfoUpdatedAt = time.Now()
artist.ExternalInfoUpdatedAt = P(time.Now())
err := e.ds.Artist(ctx).Put(&artist.Artist)
if err != nil {
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
@ -272,7 +274,7 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
return ctx.Err()
}
topCount := number.Max(count, 20)
topCount := max(count, 20)
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
@ -414,7 +416,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
},
squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
},
Sort: "starred desc, rating desc, year asc",
Sort: "starred desc, rating desc, year asc, compilation asc ",
Max: 1,
})
if err != nil || len(mfs) == 0 {

View File

@ -16,10 +16,14 @@ import (
)
type FFmpeg interface {
Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error)
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error)
ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
IsAvailable() bool
Version() string
}
func New() FFmpeg {
@ -29,15 +33,17 @@ func New() FFmpeg {
const (
extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
createWavCmd = "ffmpeg -i %s -c:a pcm_s16le -f wav -"
createFLACCmd = "ffmpeg -i %s -f flac -"
)
type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) {
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
args := createFFmpegCommand(command, path, maxBitRate)
args := createFFmpegCommand(command, path, maxBitRate, offset)
return e.start(ctx, args)
}
@ -45,7 +51,17 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
args := createFFmpegCommand(extractImageCmd, path, 0)
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
return e.start(ctx, args)
}
func (e *ffmpeg) ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error) {
args := createFFmpegCommand(createWavCmd, path, 0, 0)
return e.start(ctx, args)
}
func (e *ffmpeg) ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error) {
args := createFFmpegCommand(createFLACCmd, path, 0, 0)
return e.start(ctx, args)
}
@ -64,6 +80,29 @@ func (e *ffmpeg) CmdPath() (string, error) {
return ffmpegCmd()
}
func (e *ffmpeg) IsAvailable() bool {
_, err := ffmpegCmd()
return err == nil
}
// Version executes ffmpeg -version and extracts the version from the output.
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
func (e *ffmpeg) Version() string {
cmd, err := ffmpegCmd()
if err != nil {
return "N/A"
}
out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec
if err != nil {
return "N/A"
}
parts := strings.Split(string(out), " ")
if len(parts) < 3 {
return "N/A"
}
return parts[2]
}
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
@ -86,7 +125,7 @@ type ffCmd struct {
func (j *ffCmd) start() error {
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.CurrentLevel() >= log.LevelTrace {
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = io.Discard
@ -113,15 +152,25 @@ func (j *ffCmd) wait() {
}
// Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
split := strings.Split(fixCmd(cmd), " ")
for i, s := range split {
s = strings.ReplaceAll(s, "%s", path)
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
split[i] = s
var parts []string
for _, s := range split {
if strings.Contains(s, "%s") {
s = strings.ReplaceAll(s, "%s", path)
parts = append(parts, s)
if offset > 0 && !strings.Contains(cmd, "%t") {
parts = append(parts, "-ss", strconv.Itoa(offset))
}
} else {
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
parts = append(parts, s)
}
}
return split
return parts
}
func createProbeCommand(cmd string, inputs []string) []string {

View File

@ -24,9 +24,22 @@ var _ = Describe("ffmpeg", func() {
})
Describe("createFFmpegCommand", func() {
It("creates a valid command line", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
Context("when command has time offset param", func() {
It("creates a valid command line with offset", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
})
})
Context("when command does not have time offset param", func() {
It("adds time offset after the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
})
})
})
Describe("createProbeCommand", func() {

View File

@ -19,8 +19,8 @@ import (
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error)
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
}
type TranscodingCache cache.FileCache
@ -40,22 +40,23 @@ type streamJob struct {
mf *model.MediaFile
format string
bitRate int
offset int
}
func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format)
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
return ms.DoStream(ctx, mf, reqFormat, reqBitRate)
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error) {
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
var format string
var bitRate int
var cached bool
@ -70,7 +71,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(mf.Path)
@ -88,6 +89,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
mf: mf,
format: format,
bitRate: bitRate,
offset: reqOffset,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
@ -100,7 +102,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
@ -199,7 +201,7 @@ func NewTranscodingCache() TranscodingCache {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate)
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid

View File

@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() {
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", "raw", 0)
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
Expect(err).To(BeNil())
_, _ = io.ReadAll(s)
_ = s.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})

292
core/playback/device.go Normal file
View File

@ -0,0 +1,292 @@
package playback
import (
"context"
"errors"
"fmt"
"github.com/navidrome/navidrome/core/playback/mpv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Track interface {
IsPlaying() bool
SetVolume(value float32) // Used to control the playback volume. A float value between 0.0 and 1.0.
Pause()
Unpause()
Position() int
SetPosition(offset int) error
Close()
String() string
}
type playbackDevice struct {
ParentPlaybackServer PlaybackServer
Default bool
User string
Name string
DeviceName string
PlaybackQueue *Queue
Gain float32
PlaybackDone chan bool
ActiveTrack Track
TrackSwitcherStarted bool
}
type DeviceStatus struct {
CurrentIndex int
Playing bool
Gain float32
Position int
}
const DefaultGain float32 = 1.0
var EmptyStatus = DeviceStatus{CurrentIndex: -1, Playing: false, Gain: DefaultGain, Position: 0}
func (pd *playbackDevice) getStatus() DeviceStatus {
pos := 0
if pd.ActiveTrack != nil {
pos = pd.ActiveTrack.Position()
}
return DeviceStatus{
CurrentIndex: pd.PlaybackQueue.Index,
Playing: pd.isPlaying(),
Gain: pd.Gain,
Position: pos,
}
}
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
// Starts the trackSwitcher goroutine for the device.
func NewPlaybackDevice(playbackServer PlaybackServer, name string, deviceName string) *playbackDevice {
return &playbackDevice{
ParentPlaybackServer: playbackServer,
User: "",
Name: name,
DeviceName: deviceName,
Gain: DefaultGain,
PlaybackQueue: NewQueue(),
PlaybackDone: make(chan bool),
TrackSwitcherStarted: false,
}
}
func (pd *playbackDevice) String() string {
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
}
func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
log.Debug(ctx, "Processing Get action", "device", pd)
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
}
func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue))
return pd.getStatus(), nil
}
// Set is similar to a clear followed by a add, but will not change the currently playing track.
func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) {
log.Debug(ctx, "Processing Set action", "ids", ids, "device", pd)
_, err := pd.Clear(ctx)
if err != nil {
log.Error(ctx, "error setting tracks", ids)
return pd.getStatus(), err
}
return pd.Add(ctx, ids)
}
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Start action", "device", pd)
if !pd.TrackSwitcherStarted {
log.Info(ctx, "Starting trackSwitcher goroutine")
// Start one trackSwitcher goroutine with each device
go func() {
pd.trackSwitcherGoroutine()
}()
pd.TrackSwitcherStarted = true
}
if pd.ActiveTrack != nil {
if pd.isPlaying() {
log.Debug("trying to start an already playing track")
} else {
pd.ActiveTrack.Unpause()
}
} else {
if !pd.PlaybackQueue.IsEmpty() {
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
if err != nil {
return pd.getStatus(), err
}
pd.ActiveTrack.Unpause()
}
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Stop action", "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.Pause()
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd)
wasPlaying := pd.isPlaying()
if pd.ActiveTrack != nil && wasPlaying {
pd.ActiveTrack.Pause()
}
if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil {
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
if pd.ActiveTrack == nil {
err := pd.switchActiveTrackByIndex(index)
if err != nil {
return pd.getStatus(), err
}
}
err := pd.ActiveTrack.SetPosition(offset)
if err != nil {
log.Error(ctx, "error setting position", err)
return pd.getStatus(), err
}
if wasPlaying {
_, err = pd.Start(ctx)
if err != nil {
log.Error(ctx, "error starting new track after skipping")
return pd.getStatus(), err
}
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
log.Debug(ctx, "Processing Add action", "ids", ids, "device", pd)
if len(ids) < 1 {
return pd.getStatus(), nil
}
items := model.MediaFiles{}
for _, id := range ids {
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
if err != nil {
return DeviceStatus{}, err
}
log.Debug(ctx, "Found mediafile: "+mf.Path)
items = append(items, *mf)
}
pd.PlaybackQueue.Add(items)
return pd.getStatus(), nil
}
func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Clear action", "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.Pause()
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
pd.PlaybackQueue.Clear()
return pd.getStatus(), nil
}
func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
log.Debug(ctx, "Processing Remove action", "index", index, "device", pd)
// pausing if attempting to remove running track
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
_, err := pd.Stop(ctx)
if err != nil {
log.Error(ctx, "error stopping running track")
return pd.getStatus(), err
}
}
if index > -1 && index < pd.PlaybackQueue.Size() {
pd.PlaybackQueue.Remove(index)
} else {
log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index))
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Shuffle action", "device", pd)
if pd.PlaybackQueue.Size() > 1 {
pd.PlaybackQueue.Shuffle()
}
return pd.getStatus(), nil
}
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.SetVolume(gain)
}
pd.Gain = gain
return pd.getStatus(), nil
}
func (pd *playbackDevice) isPlaying() bool {
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
}
func (pd *playbackDevice) trackSwitcherGoroutine() {
log.Debug("Started trackSwitcher goroutine", "device", pd)
for {
<-pd.PlaybackDone
log.Debug("Track switching detected")
if pd.ActiveTrack != nil {
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
if !pd.PlaybackQueue.IsAtLastElement() {
pd.PlaybackQueue.IncreaseIndex()
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
if err != nil {
log.Error("Error switching track", err)
}
pd.ActiveTrack.Unpause()
} else {
log.Debug("There is no song left in the playlist. Finish.")
}
}
}
func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
pd.PlaybackQueue.SetIndex(index)
currentTrack := pd.PlaybackQueue.Current()
if currentTrack == nil {
return errors.New("could not get current track")
}
track, err := mpv.NewTrack(pd.PlaybackDone, pd.DeviceName, *currentTrack)
if err != nil {
return err
}
pd.ActiveTrack = track
return nil
}

132
core/playback/mpv/mpv.go Normal file
View File

@ -0,0 +1,132 @@
package mpv
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
// mpv --no-audio-display --pause 'Jack Johnson/On And On/01 Times Like These.m4a' --input-ipc-server=/tmp/gonzo.socket
const (
mpvComdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
)
func start(args []string) (Executor, error) {
log.Debug("Executing mpv command", "cmd", args)
j := Executor{args: args}
j.PipeReader, j.out = io.Pipe()
err := j.start()
if err != nil {
return Executor{}, err
}
go j.wait()
return j, nil
}
func (j *Executor) Cancel() error {
if j.cmd != nil {
return j.cmd.Cancel()
}
return fmt.Errorf("there is non command to cancel")
}
type Executor struct {
*io.PipeReader
out *io.PipeWriter
args []string
cmd *exec.Cmd
ctx context.Context
}
func (j *Executor) start() error {
ctx := context.Background()
j.ctx = ctx
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = io.Discard
}
j.cmd = cmd
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting cmd: %w", err)
}
return nil
}
func (j *Executor) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
}
return
}
_ = j.out.Close()
}
// Path will always be an absolute path
func createMPVCommand(cmd, deviceName string, filename string, socketName string) []string {
split := strings.Split(fixCmd(cmd), " ")
for i, s := range split {
s = strings.ReplaceAll(s, "%d", deviceName)
s = strings.ReplaceAll(s, "%f", filename)
s = strings.ReplaceAll(s, "%s", socketName)
split[i] = s
}
return split
}
func fixCmd(cmd string) string {
split := strings.Split(cmd, " ")
var result []string
cmdPath, _ := mpvCommand()
for _, s := range split {
if s == "mpv" || s == "mpv.exe" {
result = append(result, cmdPath)
} else {
result = append(result, s)
}
}
return strings.Join(result, " ")
}
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
func mpvCommand() (string, error) {
mpvOnce.Do(func() {
if conf.Server.MPVPath != "" {
mpvPath = conf.Server.MPVPath
mpvPath, mpvErr = exec.LookPath(mpvPath)
} else {
mpvPath, mpvErr = exec.LookPath("mpv")
if errors.Is(mpvErr, exec.ErrDot) {
log.Trace("mpv found in current folder '.'")
mpvPath, mpvErr = exec.LookPath("./mpv")
}
}
if mpvErr == nil {
log.Info("Found mpv", "path", mpvPath)
return
}
})
return mpvPath, mpvErr
}
var (
mpvOnce sync.Once
mpvPath string
mpvErr error
)

224
core/playback/mpv/track.go Normal file
View File

@ -0,0 +1,224 @@
package mpv
// Audio-playback using mpv media-server. See mpv.io
// https://github.com/dexterlb/mpvipc
// https://mpv.io/manual/master/#json-ipc
// https://mpv.io/manual/master/#properties
import (
"fmt"
"os"
"time"
"github.com/dexterlb/mpvipc"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
type MpvTrack struct {
MediaFile model.MediaFile
PlaybackDone chan bool
Conn *mpvipc.Connection
IPCSocketName string
Exe *Executor
CloseCalled bool
}
func NewTrack(playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType())
if _, err := mpvCommand(); err != nil {
return nil, err
}
tmpSocketName := utils.TempFileName("mpv-ctrl-", ".socket")
args := createMPVCommand(mpvComdTemplate, deviceName, mf.Path, tmpSocketName)
exe, err := start(args)
if err != nil {
log.Error("Error starting mpv process", err)
return nil, err
}
// wait for socket to show up
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
if err != nil {
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
return nil, err
}
conn := mpvipc.NewConnection(tmpSocketName)
err = conn.Open()
if err != nil {
log.Error("Error opening new connection", err)
return nil, err
}
theTrack := &MpvTrack{MediaFile: mf, PlaybackDone: playbackDoneChannel, Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
go func() {
conn.WaitUntilClosed()
log.Info("Hitting end-of-stream, signalling on channel")
if !theTrack.CloseCalled {
playbackDoneChannel <- true
}
}()
return theTrack, nil
}
func (t *MpvTrack) String() string {
return fmt.Sprintf("Name: %s, Socket: %s", t.MediaFile.Path, t.IPCSocketName)
}
// Used to control the playback volume. A float value between 0.0 and 1.0.
func (t *MpvTrack) SetVolume(value float32) {
// mpv's volume as described in the --volume parameter:
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
// Negative values can be passed for compatibility, but are treated as 0.
log.Debug("Setting volume", "volume", value, "track", t)
vol := int(value * 100)
err := t.Conn.Set("volume", vol)
if err != nil {
log.Error("Error setting volume", "volume", value, "track", t, err)
}
}
func (t *MpvTrack) Unpause() {
log.Debug("Unpausing track", "track", t)
err := t.Conn.Set("pause", false)
if err != nil {
log.Error("Error unpausing track", "track", t, err)
}
}
func (t *MpvTrack) Pause() {
log.Debug("Pausing track", "track", t)
err := t.Conn.Set("pause", true)
if err != nil {
log.Error("Error pausing track", "track", t, err)
}
}
func (t *MpvTrack) Close() {
log.Debug("Closing resources", "track", t)
t.CloseCalled = true
// trying to shutdown mpv process using socket
if t.isSocketFilePresent() {
log.Debug("sending shutdown command")
_, err := t.Conn.Call("quit")
if err != nil {
log.Error("Error sending quit command to mpv-ipc socket", err)
if t.Exe != nil {
log.Debug("cancelling executor")
err = t.Exe.Cancel()
if err != nil {
log.Error("Error canceling executor", err)
}
}
}
}
if t.isSocketFilePresent() {
log.Debug("Removing socketfile", "socketfile", t.IPCSocketName)
err := os.Remove(t.IPCSocketName)
if err != nil {
log.Error("Error cleaning up socketfile", "socketfile", t.IPCSocketName, err)
}
}
}
func (t *MpvTrack) isSocketFilePresent() bool {
if len(t.IPCSocketName) < 1 {
return false
}
fileInfo, err := os.Stat(t.IPCSocketName)
return err == nil && fileInfo != nil && !fileInfo.IsDir()
}
// Position returns the playback position in seconds.
// Every now and then the mpv IPC interface returns "mpv error: property unavailable"
// in this case we have to retry
func (t *MpvTrack) Position() int {
retryCount := 0
for {
position, err := t.Conn.Get("time-pos")
if err != nil && err.Error() == "mpv error: property unavailable" {
retryCount += 1
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
if retryCount > 5 {
return 0
}
break
}
if err != nil {
log.Error("Error getting position in track", "track", t, err)
return 0
}
pos, ok := position.(float64)
if !ok {
log.Error("Could not cast position from mpv into float64", "position", position, "track", t)
return 0
} else {
return int(pos)
}
}
return 0
}
func (t *MpvTrack) SetPosition(offset int) error {
log.Debug("Setting position", "offset", offset, "track", t)
pos := t.Position()
if pos == offset {
log.Debug("No position difference, skipping operation", "track", t)
return nil
}
err := t.Conn.Set("time-pos", float64(offset))
if err != nil {
log.Error("Could not set the position in track", "track", t, "offset", offset, err)
return err
}
return nil
}
func (t *MpvTrack) IsPlaying() bool {
log.Debug("Checking if track is playing", "track", t)
pausing, err := t.Conn.Get("pause")
if err != nil {
log.Error("Problem getting paused status", "track", t, err)
return false
}
pause, ok := pausing.(bool)
if !ok {
log.Error("Could not cast pausing to boolean", "track", t, "value", pausing)
return false
}
return !pause
}
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
start := time.Now()
end := start.Add(timeout)
var retries int = 0
for {
fileInfo, err := os.Stat(path)
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start))
return nil
}
if time.Now().After(end) {
return fmt.Errorf("timeout reached: %s", timeout)
}
time.Sleep(pause)
retries += 1
}
}

View File

@ -0,0 +1,17 @@
package playback
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPlayback(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Playback Suite")
}

View File

@ -0,0 +1,135 @@
// Package playback implements audio playback using PlaybackDevices. It is used to implement the Jukebox mode in turn.
// It makes use of the MPV library to do the playback. Major parts are:
// - decoder which includes decoding and transcoding of various audio file formats
// - device implementing the basic functions to work with audio devices like set, play, stop, skip, ...
// - queue a simple playlist
package playback
import (
"context"
"fmt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils/singleton"
)
type PlaybackServer interface {
Run(ctx context.Context) error
GetDeviceForUser(user string) (*playbackDevice, error)
GetMediaFile(id string) (*model.MediaFile, error)
GetCtx() *context.Context
}
type playbackServer struct {
ctx *context.Context
datastore model.DataStore
playbackDevices []playbackDevice
}
// GetInstance returns the playback-server singleton
func GetInstance() PlaybackServer {
return singleton.GetInstance(func() *playbackServer {
return &playbackServer{}
})
}
// Run starts the playback server which serves request until canceled using the given context
func (ps *playbackServer) Run(ctx context.Context) error {
ps.datastore = persistence.New(db.Db())
devices, err := ps.initDeviceStatus(conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
ps.playbackDevices = devices
if err != nil {
return err
}
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices)))
defaultDevice, _ := ps.getDefaultDevice()
log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName)
ps.ctx = &ctx
<-ctx.Done()
return nil
}
// GetCtx produces the context this server was started with. Used for data-retrieval and cancellation
func (ps *playbackServer) GetCtx() *context.Context {
return ps.ctx
}
func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
pbDevices := make([]playbackDevice, max(1, len(devices)))
defaultDeviceFound := false
if defaultDevice == "" {
// if there are no devices given and no default device, we create a synthetic device named "auto"
if len(devices) == 0 {
pbDevices[0] = *NewPlaybackDevice(ps, "auto", "auto")
}
// if there is but only one entry and no default given, just use that.
if len(devices) == 1 {
if len(devices[0]) != 2 {
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0]))
}
pbDevices[0] = *NewPlaybackDevice(ps, devices[0][0], devices[0][1])
}
if len(devices) > 1 {
return []playbackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices))
}
pbDevices[0].Default = true
return pbDevices, nil
}
for idx, audioDevice := range devices {
if len(audioDevice) != 2 {
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice))
}
pbDevices[idx] = *NewPlaybackDevice(ps, audioDevice[0], audioDevice[1])
if audioDevice[0] == defaultDevice {
pbDevices[idx].Default = true
defaultDeviceFound = true
}
}
if !defaultDeviceFound {
return []playbackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice)
}
return pbDevices, nil
}
func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
for idx, audioDevice := range ps.playbackDevices {
if audioDevice.Default {
return &ps.playbackDevices[idx], nil
}
}
return &playbackDevice{}, fmt.Errorf("no default device found")
}
// GetMediaFile retrieves the MediaFile given by the id parameter
func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
return ps.datastore.MediaFile(*ps.ctx).Get(id)
}
// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device.
func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error) {
log.Debug("Processing GetDevice", "user", user)
// README: here we might plug-in the user-device mapping one fine day
device, err := ps.getDefaultDevice()
if err != nil {
return &playbackDevice{}, err
}
device.User = user
return device, nil
}

150
core/playback/queue.go Normal file
View File

@ -0,0 +1,150 @@
package playback
import (
"fmt"
"math/rand"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Queue struct {
Index int
Items model.MediaFiles
}
func NewQueue() *Queue {
return &Queue{
Index: -1,
Items: model.MediaFiles{},
}
}
func (pd *Queue) String() string {
filenames := ""
for idx, item := range pd.Items {
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
}
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
}
// returns the current mediafile or nil
func (pd *Queue) Current() *model.MediaFile {
if pd.Index == -1 {
return nil
}
if pd.Index >= len(pd.Items) {
log.Error("internal error: current song index out of bounds", "idx", pd.Index, "length", len(pd.Items))
return nil
}
return &pd.Items[pd.Index]
}
// returns the whole queue
func (pd *Queue) Get() model.MediaFiles {
return pd.Items
}
func (pd *Queue) Size() int {
return len(pd.Items)
}
func (pd *Queue) IsEmpty() bool {
return len(pd.Items) < 1
}
// set is similar to a clear followed by a add, but will not change the currently playing track.
func (pd *Queue) Set(items model.MediaFiles) {
pd.Clear()
pd.Items = append(pd.Items, items...)
}
// adding mediafiles to the queue
func (pd *Queue) Add(items model.MediaFiles) {
pd.Items = append(pd.Items, items...)
if pd.Index == -1 && len(pd.Items) > 0 {
pd.Index = 0
}
}
// empties whole queue
func (pd *Queue) Clear() {
pd.Index = -1
pd.Items = nil
}
// idx Zero-based index of the song to skip to or remove.
func (pd *Queue) Remove(idx int) {
current := pd.Current()
backupID := ""
if current != nil {
backupID = current.ID
}
pd.Items = append(pd.Items[:idx], pd.Items[idx+1:]...)
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
// we seem to have deleted the current id, setting to default:
pd.Index = -1
}
}
func (pd *Queue) Shuffle() {
current := pd.Current()
backupID := ""
if current != nil {
backupID = current.ID
}
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
log.Error("Could not find ID while shuffling: " + backupID)
}
}
func (pd *Queue) getMediaFileIndexByID(id string) (int, error) {
for idx, item := range pd.Items {
if item.ID == id {
return idx, nil
}
}
return -1, fmt.Errorf("ID not found in playlist: " + id)
}
// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero,
// values above will be limited by number of items.
func (pd *Queue) SetIndex(idx int) {
pd.Index = max(0, min(idx, len(pd.Items)-1))
}
// Are we at the last track?
func (pd *Queue) IsAtLastElement() bool {
return (pd.Index + 1) >= len(pd.Items)
}
// Goto next index
func (pd *Queue) IncreaseIndex() {
if !pd.IsAtLastElement() {
pd.SetIndex(pd.Index + 1)
}
}
func max(x, y int) int {
if x < y {
return y
}
return x
}
func min(x, y int) int {
if x > y {
return y
}
return x
}

121
core/playback/queue_test.go Normal file
View File

@ -0,0 +1,121 @@
package playback
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Queues", func() {
var queue *Queue
BeforeEach(func() {
queue = NewQueue()
})
Describe("use empty queue", func() {
It("is empty", func() {
Expect(queue.Items).To(BeEmpty())
Expect(queue.Index).To(Equal(-1))
})
})
Describe("Operate on small queue", func() {
BeforeEach(func() {
mfs := model.MediaFiles{
{
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
},
{
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
},
}
queue.Add(mfs)
})
It("contains the preloaded data", func() {
Expect(queue.Get).ToNot(BeNil())
Expect(queue.Size()).To(Equal(2))
})
It("could read data by ID", func() {
idx, err := queue.getMediaFileIndexByID("1")
Expect(err).ToNot(HaveOccurred())
Expect(idx).ToNot(BeNil())
Expect(idx).To(Equal(0))
queue.SetIndex(idx)
mf := queue.Current()
Expect(mf).ToNot(BeNil())
Expect(mf.ID).To(Equal("1"))
Expect(mf.Artist).To(Equal("Queen"))
Expect(mf.Path).To(Equal("/music1/hammer.mp3"))
})
})
Describe("Read/Write operations", func() {
BeforeEach(func() {
mfs := model.MediaFiles{
{
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
},
{
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
},
{
ID: "3", Artist: "Pink Floyd", Compilation: false, Path: "/music1/time.mp3",
},
{
ID: "4", Artist: "Mike Oldfield", Compilation: false, Path: "/music1/moonlight-shadow.mp3",
},
{
ID: "5", Artist: "Red Hot Chili Peppers", Compilation: false, Path: "/music1/californication.mp3",
},
}
queue.Add(mfs)
})
It("contains the preloaded data", func() {
Expect(queue.Get).ToNot(BeNil())
Expect(queue.Size()).To(Equal(5))
})
It("could read data by ID", func() {
idx, err := queue.getMediaFileIndexByID("5")
Expect(err).ToNot(HaveOccurred())
Expect(idx).ToNot(BeNil())
Expect(idx).To(Equal(4))
queue.SetIndex(idx)
mf := queue.Current()
Expect(mf).ToNot(BeNil())
Expect(mf.ID).To(Equal("5"))
Expect(mf.Artist).To(Equal("Red Hot Chili Peppers"))
Expect(mf.Path).To(Equal("/music1/californication.mp3"))
})
It("could shuffle the data correctly", func() {
queue.Shuffle()
Expect(queue.Size()).To(Equal(5))
})
It("could remove entries correctly", func() {
queue.Remove(0)
Expect(queue.Size()).To(Equal(4))
queue.Remove(3)
Expect(queue.Size()).To(Equal(3))
})
It("clear the whole thing on request", func() {
Expect(queue.Size()).To(Equal(5))
queue.Clear()
Expect(queue.Size()).To(Equal(0))
})
})
})

View File

@ -23,6 +23,7 @@ import (
type Playlists interface {
ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error)
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
}
type playlists struct {
@ -47,6 +48,26 @@ func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*
return pls, err
}
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
owner, _ := request.UserFrom(ctx)
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: true,
}
pls, err := s.parseM3U(ctx, pls, "", reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
}
return pls, nil
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(baseDir, playlistFile)
if err != nil {
@ -107,31 +128,40 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
return pls, nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) {
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) (*model.Playlist, error) {
mediaFileRepository := s.ds.MediaFile(ctx)
scanner := bufio.NewScanner(file)
scanner := bufio.NewScanner(reader)
scanner.Split(scanLines)
var mfs model.MediaFiles
for scanner.Scan() {
path := strings.TrimSpace(scanner.Text())
// Skip empty lines and extended info
if path == "" || strings.HasPrefix(path, "#") {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#PLAYLIST:") {
if split := strings.Split(line, ":"); len(split) >= 2 {
pls.Name = split[1]
}
continue
}
if strings.HasPrefix(path, "file://") {
path = strings.TrimPrefix(path, "file://")
path, _ = url.QueryUnescape(path)
// Skip empty lines and extended info
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if !filepath.IsAbs(path) {
path = filepath.Join(baseDir, path)
if strings.HasPrefix(line, "file://") {
line = strings.TrimPrefix(line, "file://")
line, _ = url.QueryUnescape(line)
}
mf, err := mediaFileRepository.FindByPath(path)
if baseDir != "" && !filepath.IsAbs(line) {
line = filepath.Join(baseDir, line)
}
mf, err := mediaFileRepository.FindByPath(line)
if err != nil {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err)
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", line, err)
continue
}
mfs = append(mfs, *mf)
}
if pls.Name == "" {
pls.Name = time.Now().Format(time.RFC3339)
}
pls.Tracks = nil
pls.AddMediaFiles(mfs)
@ -157,7 +187,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.EvaluatedAt = time.Time{}
newPls.EvaluatedAt = &time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID

View File

@ -2,6 +2,10 @@ package core
import (
"context"
"os"
"time"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@ -12,13 +16,16 @@ import (
var _ = Describe("Playlists", func() {
var ds model.DataStore
var ps Playlists
var mp mockedPlaylist
ctx := context.Background()
BeforeEach(func() {
mp = mockedPlaylist{}
ds = &tests.MockDataStore{
MockedMediaFile: &mockedMediaFile{},
MockedPlaylist: &mockedPlaylist{},
MockedPlaylist: &mp,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("ImportFile", func() {
@ -29,10 +36,12 @@ var _ = Describe("Playlists", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
Expect(err).To(BeNil())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
@ -48,6 +57,37 @@ var _ = Describe("Playlists", func() {
})
})
Describe("ImportM3U", func() {
BeforeEach(func() {
ps = NewPlaylists(ds)
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
It("parses well-formed playlists", func() {
f, _ := os.Open("tests/fixtures/playlists/pls-post-with-name.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("playlist 1"))
Expect(err).To(BeNil())
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
f.Close()
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
f, _ := os.Open("tests/fixtures/playlists/pls-post.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(err).To(BeNil())
_, err = time.Parse(time.RFC3339, pls.Name)
Expect(err).To(BeNil())
})
})
})
type mockedMediaFile struct {
@ -62,6 +102,7 @@ func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
}
type mockedPlaylist struct {
last *model.Playlist
model.PlaylistRepository
}
@ -69,6 +110,7 @@ func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
return nil, model.ErrNotFound
}
func (r *mockedPlaylist) Put(*model.Playlist) error {
func (r *mockedPlaylist) Put(pls *model.Playlist) error {
r.last = pls
return nil
}

View File

@ -5,10 +5,9 @@ import (
"sort"
"time"
"github.com/jellydator/ttlcache/v2"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/ReneKroon/ttlcache/v2"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"

View File

@ -10,6 +10,7 @@ import (
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
)
@ -34,10 +35,11 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
if err != nil {
return nil, err
}
if !share.ExpiresAt.IsZero() && share.ExpiresAt.Before(time.Now()) {
expiresAt := V(share.ExpiresAt)
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
return nil, model.ErrExpired
}
share.LastVisitedAt = time.Now()
share.LastVisitedAt = P(time.Now())
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
@ -90,8 +92,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
return "", err
}
s.ID = id
if s.ExpiresAt.IsZero() {
s.ExpiresAt = time.Now().Add(365 * 24 * time.Hour)
if V(s.ExpiresAt).IsZero() {
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
}
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
@ -128,7 +130,7 @@ func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...stri
cols := []string{"description", "downloadable"}
// TODO Better handling of Share expiration
if !entity.(*model.Share).ExpiresAt.IsZero() {
if !V(entity.(*model.Share).ExpiresAt).IsZero() {
cols = append(cols, "expires_at")
}
return r.Persistable.Update(id, entity, cols...)

View File

@ -60,19 +60,46 @@ func Init() {
}
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
goose.SetLogger(gooseLogger)
goose.SetBaseFS(embedMigrations)
err = goose.SetDialect(Driver)
if err != nil {
log.Fatal("Invalid DB driver", "driver", Driver, err)
}
if !isSchemaEmpty(db) && hasPendingMigrations(db, migrationsFolder) {
log.Info("Upgrading DB Schema to latest version")
}
goose.SetLogger(gooseLogger)
err = goose.Up(db, migrationsFolder)
if err != nil {
log.Fatal("Failed to apply new migrations", err)
}
}
type statusLogger struct{ numPending int }
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...interface{}) {
if len(v) < 1 {
return
}
if v0, ok := v[0].(string); !ok {
return
} else if v0 == "Pending" {
l.numPending++
}
}
func hasPendingMigrations(db *sql.DB, folder string) bool {
l := &statusLogger{}
goose.SetLogger(l)
err := goose.Status(db, folder)
if err != nil {
log.Fatal("Failed to check for pending migrations", err)
}
return l.numPending > 0
}
func isSchemaEmpty(db *sql.DB) bool {
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck
if err != nil {

View File

@ -1,6 +1,7 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/log"
@ -8,10 +9,10 @@ import (
)
func init() {
goose.AddMigration(Up20200130083147, Down20200130083147)
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
}
func Up20200130083147(tx *sql.Tx) error {
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
log.Info("Creating DB Schema")
_, err := tx.Exec(`
create table if not exists album
@ -178,6 +179,6 @@ create table if not exists user
return err
}
func Down20200130083147(tx *sql.Tx) error {
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200131183653, Down20200131183653)
goose.AddMigrationContext(Up20200131183653, Down20200131183653)
}
func Up20200131183653(tx *sql.Tx) error {
func Up20200131183653(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table search_dg_tmp
(
@ -36,7 +37,7 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
return err
}
func Down20200131183653(tx *sql.Tx) error {
func Down20200131183653(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table search_dg_tmp
(

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200208222418, Down20200208222418)
goose.AddMigrationContext(Up20200208222418, Down20200208222418)
}
func Up20200208222418(tx *sql.Tx) error {
func Up20200208222418(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
update annotation set play_count = 0 where play_count is null;
update annotation set rating = 0 where rating is null;
@ -50,6 +51,6 @@ create index annotation_starred
return err
}
func Down20200208222418(tx *sql.Tx) error {
func Down20200208222418(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200220143731, Down20200220143731)
goose.AddMigrationContext(Up20200220143731, Down20200220143731)
}
func Up20200220143731(tx *sql.Tx) error {
func Up20200220143731(_ context.Context, tx *sql.Tx) error {
notice(tx, "This migration will force the next scan to be a full rescan!")
_, err := tx.Exec(`
create table media_file_dg_tmp
@ -124,6 +125,6 @@ update media_file set updated_at = '0001-01-01';
return err
}
func Down20200220143731(tx *sql.Tx) error {
func Down20200220143731(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,20 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200310171621, Down20200310171621)
goose.AddMigrationContext(Up20200310171621, Down20200310171621)
}
func Up20200310171621(tx *sql.Tx) error {
func Up20200310171621(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
return forceFullRescan(tx)
}
func Down20200310171621(tx *sql.Tx) error {
func Down20200310171621(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200310181627, Down20200310181627)
goose.AddMigrationContext(Up20200310181627, Down20200310181627)
}
func Up20200310181627(tx *sql.Tx) error {
func Up20200310181627(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table transcoding
(
@ -44,7 +45,7 @@ create table player
return err
}
func Down20200310181627(tx *sql.Tx) error {
func Down20200310181627(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
drop table transcoding;
drop table player;

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200319211049, Down20200319211049)
goose.AddMigrationContext(Up20200319211049, Down20200319211049)
}
func Up20200319211049(tx *sql.Tx) error {
func Up20200319211049(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add full_text varchar(255) default '';
@ -36,6 +37,6 @@ drop table if exists search;
return forceFullRescan(tx)
}
func Down20200319211049(tx *sql.Tx) error {
func Down20200319211049(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200325185135, Down20200325185135)
goose.AddMigrationContext(Up20200325185135, Down20200325185135)
}
func Up20200325185135(tx *sql.Tx) error {
func Up20200325185135(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album
add album_artist_id varchar(255) default '';
@ -29,6 +30,6 @@ create index media_file_artist_album_id
return forceFullRescan(tx)
}
func Down20200325185135(tx *sql.Tx) error {
func Down20200325185135(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,20 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200326090707, Down20200326090707)
goose.AddMigrationContext(Up20200326090707, Down20200326090707)
}
func Up20200326090707(tx *sql.Tx) error {
func Up20200326090707(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200326090707(tx *sql.Tx) error {
func Down20200326090707(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200327193744, Down20200327193744)
goose.AddMigrationContext(Up20200327193744, Down20200327193744)
}
func Up20200327193744(tx *sql.Tx) error {
func Up20200327193744(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table album_dg_tmp
(
@ -75,6 +76,6 @@ create index album_max_year
return forceFullRescan(tx)
}
func Down20200327193744(tx *sql.Tx) error {
func Down20200327193744(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200404214704, Down20200404214704)
goose.AddMigrationContext(Up20200404214704, Down20200404214704)
}
func Up20200404214704(tx *sql.Tx) error {
func Up20200404214704(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists media_file_year
on media_file (year);
@ -24,6 +25,6 @@ create index if not exists media_file_track_number
return err
}
func Down20200404214704(tx *sql.Tx) error {
func Down20200404214704(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,20 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200409002249, Down20200409002249)
goose.AddMigrationContext(Up20200409002249, Down20200409002249)
}
func Up20200409002249(tx *sql.Tx) error {
func Up20200409002249(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
return forceFullRescan(tx)
}
func Down20200409002249(tx *sql.Tx) error {
func Down20200409002249(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200411164603, Down20200411164603)
goose.AddMigrationContext(Up20200411164603, Down20200411164603)
}
func Up20200411164603(tx *sql.Tx) error {
func Up20200411164603(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table playlist
add created_at datetime;
@ -22,6 +23,6 @@ update playlist
return err
}
func Down20200411164603(tx *sql.Tx) error {
func Down20200411164603(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,20 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200418110522, Down20200418110522)
goose.AddMigrationContext(Up20200418110522, Down20200418110522)
}
func Up20200418110522(tx *sql.Tx) error {
func Up20200418110522(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to fix search Albums by year")
return forceFullRescan(tx)
}
func Down20200418110522(tx *sql.Tx) error {
func Down20200418110522(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,20 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200419222708, Down20200419222708)
goose.AddMigrationContext(Up20200419222708, Down20200419222708)
}
func Up20200419222708(tx *sql.Tx) error {
func Up20200419222708(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(tx)
}
func Down20200419222708(tx *sql.Tx) error {
func Down20200419222708(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200423204116, Down20200423204116)
goose.AddMigrationContext(Up20200423204116, Down20200423204116)
}
func Up20200423204116(tx *sql.Tx) error {
func Up20200423204116(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add order_artist_name varchar(255) collate nocase;
@ -60,6 +61,6 @@ create index if not exists media_file_order_artist_name
return forceFullRescan(tx)
}
func Down20200423204116(tx *sql.Tx) error {
func Down20200423204116(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200508093059, Down20200508093059)
goose.AddMigrationContext(Up20200508093059, Down20200508093059)
}
func Up20200508093059(tx *sql.Tx) error {
func Up20200508093059(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add song_count integer default 0 not null;
@ -22,6 +23,6 @@ alter table artist
return forceFullRescan(tx)
}
func Down20200508093059(tx *sql.Tx) error {
func Down20200508093059(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200512104202, Down20200512104202)
goose.AddMigrationContext(Up20200512104202, Down20200512104202)
}
func Up20200512104202(tx *sql.Tx) error {
func Up20200512104202(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add disc_subtitle varchar(255);
@ -22,6 +23,6 @@ alter table media_file
return forceFullRescan(tx)
}
func Down20200512104202(tx *sql.Tx) error {
func Down20200512104202(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,6 +1,7 @@
package migrations
import (
"context"
"database/sql"
"strings"
@ -9,10 +10,10 @@ import (
)
func init() {
goose.AddMigration(Up20200516140647, Down20200516140647)
goose.AddMigrationContext(Up20200516140647, Down20200516140647)
}
func Up20200516140647(tx *sql.Tx) error {
func Up20200516140647(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table if not exists playlist_tracks
(
@ -95,6 +96,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string)
return nil
}
func Down20200516140647(tx *sql.Tx) error {
func Down20200516140647(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200608153717, Down20200608153717)
goose.AddMigrationContext(Up20200608153717, Down20200608153717)
}
func Up20200608153717(tx *sql.Tx) error {
func Up20200608153717(_ context.Context, tx *sql.Tx) error {
// First delete dangling players
_, err := tx.Exec(`
delete from player where user_name not in (select user_name from user)`)
@ -132,6 +133,6 @@ create unique index playlist_tracks_pos
return err
}
func Down20200608153717(tx *sql.Tx) error {
func Down20200608153717(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,6 +1,7 @@
package migrations
import (
"context"
"database/sql"
"github.com/google/uuid"
@ -9,10 +10,10 @@ import (
)
func init() {
goose.AddMigration(upAddDefaultTranscodings, downAddDefaultTranscodings)
goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings)
}
func upAddDefaultTranscodings(tx *sql.Tx) error {
func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
row := tx.QueryRow("SELECT COUNT(*) FROM transcoding")
var count int
err := row.Scan(&count)
@ -37,6 +38,6 @@ func upAddDefaultTranscodings(tx *sql.Tx) error {
return nil
}
func downAddDefaultTranscodings(tx *sql.Tx) error {
func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddPlaylistPath, downAddPlaylistPath)
goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath)
}
func upAddPlaylistPath(tx *sql.Tx) error {
func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table playlist
add path string default '' not null;
@ -22,6 +23,6 @@ alter table playlist
return err
}
func downAddPlaylistPath(tx *sql.Tx) error {
func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
}
func upCreatePlayQueuesTable(tx *sql.Tx) error {
func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table playqueue
(
@ -31,6 +32,6 @@ create table playqueue
return err
}
func downCreatePlayQueuesTable(tx *sql.Tx) error {
func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upCreateBookmarkTable, downCreateBookmarkTable)
goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable)
}
func upCreateBookmarkTable(tx *sql.Tx) error {
func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table bookmark
(
@ -48,6 +49,6 @@ alter table playqueue_dg_tmp rename to playqueue;
return err
}
func downCreateBookmarkTable(tx *sql.Tx) error {
func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
}
func upDropEmailUniqueConstraint(tx *sql.Tx) error {
func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table user_dg_tmp
(
@ -37,6 +38,6 @@ alter table user_dg_tmp rename to user;
return err
}
func downDropEmailUniqueConstraint(tx *sql.Tx) error {
func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20201003111749, Down20201003111749)
goose.AddMigrationContext(Up20201003111749, Down20201003111749)
}
func Up20201003111749(tx *sql.Tx) error {
func Up20201003111749(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists annotation_starred_at
on annotation (starred_at);
@ -18,6 +19,6 @@ create index if not exists annotation_starred_at
return err
}
func Down20201003111749(tx *sql.Tx) error {
func Down20201003111749(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20201010162350, Down20201010162350)
goose.AddMigrationContext(Up20201010162350, Down20201010162350)
}
func Up20201010162350(tx *sql.Tx) error {
func Up20201010162350(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album
add size integer default 0 not null;
@ -27,7 +28,7 @@ where id not null;`)
return err
}
func Down20201010162350(tx *sql.Tx) error {
func Down20201010162350(_ context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20201012210022, Down20201012210022)
goose.AddMigrationContext(Up20201012210022, Down20201012210022)
}
func Up20201012210022(tx *sql.Tx) error {
func Up20201012210022(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add size integer default 0 not null;
@ -39,6 +40,6 @@ update playlist set size = ifnull((
return err
}
func Down20201012210022(tx *sql.Tx) error {
func Down20201012210022(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20201021085410, Down20201021085410)
goose.AddMigrationContext(Up20201021085410, Down20201021085410)
}
func Up20201021085410(tx *sql.Tx) error {
func Up20201021085410(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add mbz_track_id varchar(255);
@ -52,7 +53,7 @@ alter table artist
return forceFullRescan(tx)
}
func Down20201021085410(tx *sql.Tx) error {
func Down20201021085410(_ context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20201021093209, Down20201021093209)
goose.AddMigrationContext(Up20201021093209, Down20201021093209)
}
func Up20201021093209(tx *sql.Tx) error {
func Up20201021093209(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists media_file_artist
on media_file (artist);
@ -22,6 +23,6 @@ create index if not exists media_file_mbz_track_id
return err
}
func Down20201021093209(tx *sql.Tx) error {
func Down20201021093209(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20201021135455, Down20201021135455)
goose.AddMigrationContext(Up20201021135455, Down20201021135455)
}
func Up20201021135455(tx *sql.Tx) error {
func Up20201021135455(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists media_file_artist_id
on media_file (artist_id);
@ -18,6 +19,6 @@ create index if not exists media_file_artist_id
return err
}
func Down20201021135455(tx *sql.Tx) error {
func Down20201021135455(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddArtistImageUrl, downAddArtistImageUrl)
goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl)
}
func upAddArtistImageUrl(tx *sql.Tx) error {
func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table artist
add biography varchar(255) default '' not null;
@ -30,6 +31,6 @@ alter table artist
return err
}
func downAddArtistImageUrl(tx *sql.Tx) error {
func downAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20201110205344, Down20201110205344)
goose.AddMigrationContext(Up20201110205344, Down20201110205344)
}
func Up20201110205344(tx *sql.Tx) error {
func Up20201110205344(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add comment varchar;
@ -27,6 +28,6 @@ alter table album
return forceFullRescan(tx)
}
func Down20201110205344(tx *sql.Tx) error {
func Down20201110205344(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20201128100726, Down20201128100726)
goose.AddMigrationContext(Up20201128100726, Down20201128100726)
}
func Up20201128100726(tx *sql.Tx) error {
func Up20201128100726(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table player
add report_real_path bool default FALSE not null;
@ -18,6 +19,6 @@ alter table player
return err
}
func Down20201128100726(tx *sql.Tx) error {
func Down20201128100726(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,6 +1,7 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/log"
@ -9,10 +10,10 @@ import (
)
func init() {
goose.AddMigration(Up20201213124814, Down20201213124814)
goose.AddMigrationContext(Up20201213124814, Down20201213124814)
}
func Up20201213124814(tx *sql.Tx) error {
func Up20201213124814(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album
add all_artist_ids varchar;
@ -58,6 +59,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id,
return rows.Err()
}
func Down20201213124814(tx *sql.Tx) error {
func Down20201213124814(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddTimestampIndexesGo, downAddTimestampIndexesGo)
goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo)
}
func upAddTimestampIndexesGo(tx *sql.Tx) error {
func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index if not exists album_updated_at
on album (updated_at);
@ -28,6 +29,6 @@ create index if not exists media_file_updated_at
return err
}
func downAddTimestampIndexesGo(tx *sql.Tx) error {
func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,6 +1,7 @@
package migrations
import (
"context"
"database/sql"
"strings"
@ -10,10 +11,10 @@ import (
)
func init() {
goose.AddMigration(upFixAlbumComments, downFixAlbumComments)
goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments)
}
func upFixAlbumComments(tx *sql.Tx) error {
func upFixAlbumComments(_ context.Context, tx *sql.Tx) error {
//nolint:gosec
rows, err := tx.Query(`
SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
@ -48,7 +49,7 @@ func upFixAlbumComments(tx *sql.Tx) error {
return rows.Err()
}
func downFixAlbumComments(tx *sql.Tx) error {
func downFixAlbumComments(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddBpmMetadata, downAddBpmMetadata)
goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata)
}
func upAddBpmMetadata(tx *sql.Tx) error {
func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add bpm integer;
@ -25,6 +26,6 @@ create index if not exists media_file_bpm
return forceFullRescan(tx)
}
func downAddBpmMetadata(tx *sql.Tx) error {
func downAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upCreateSharesTable, downCreateSharesTable)
goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable)
}
func upCreateSharesTable(tx *sql.Tx) error {
func upCreateSharesTable(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table share
(
@ -29,6 +30,6 @@ create table share
return err
}
func downCreateSharesTable(tx *sql.Tx) error {
func downCreateSharesTable(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upUpdateShareFieldNames, downUpdateShareFieldNames)
goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames)
}
func upUpdateShareFieldNames(tx *sql.Tx) error {
func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table share rename column expires to expires_at;
alter table share rename column created to created_at;
@ -20,6 +21,6 @@ alter table share rename column last_visited to last_visited_at;
return err
}
func downUpdateShareFieldNames(tx *sql.Tx) error {
func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -12,10 +12,10 @@ import (
)
func init() {
goose.AddMigration(upEncodeAllPasswords, downEncodeAllPasswords)
goose.AddMigrationContext(upEncodeAllPasswords, downEncodeAllPasswords)
}
func upEncodeAllPasswords(tx *sql.Tx) error {
func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.Query(`SELECT id, user_name, password from user;`)
if err != nil {
return err
@ -38,7 +38,7 @@ func upEncodeAllPasswords(tx *sql.Tx) error {
return err
}
password, err = utils.Encrypt(context.Background(), encKey, password)
password, err = utils.Encrypt(ctx, encKey, password)
if err != nil {
log.Error("Error encrypting user's password", "id", id, "username", username, err)
}
@ -51,6 +51,6 @@ func upEncodeAllPasswords(tx *sql.Tx) error {
return rows.Err()
}
func downEncodeAllPasswords(tx *sql.Tx) error {
func downEncodeAllPasswords(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint)
goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint)
}
func upDropPlayerNameUniqueConstraint(tx *sql.Tx) error {
func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table player_dg_tmp
(
@ -42,6 +43,6 @@ create index if not exists player_name
return err
}
func downDropPlayerNameUniqueConstraint(tx *sql.Tx) error {
func downDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled)
goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled)
}
func upAddUserPrefsPlayerScrobblerEnabled(tx *sql.Tx) error {
func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error {
err := upAddUserPrefs(tx)
if err != nil {
return err
@ -39,6 +40,6 @@ alter table player add scrobble_enabled bool default true;
return err
}
func downAddUserPrefsPlayerScrobblerEnabled(tx *sql.Tx) error {
func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps)
goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps)
}
func upAddReferentialIntegrityToUserProps(tx *sql.Tx) error {
func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table user_props_dg_tmp
(
@ -33,6 +34,6 @@ alter table user_props_dg_tmp rename to user_props;
return err
}
func downAddReferentialIntegrityToUserProps(tx *sql.Tx) error {
func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddScrobbleBuffer, downAddScrobbleBuffer)
goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer)
}
func upAddScrobbleBuffer(tx *sql.Tx) error {
func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table if not exists scrobble_buffer
(
@ -33,6 +34,6 @@ create table if not exists scrobble_buffer
return err
}
func downAddScrobbleBuffer(tx *sql.Tx) error {
func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddGenreTables, downAddGenreTables)
goose.AddMigrationContext(upAddGenreTables, downAddGenreTables)
}
func upAddGenreTables(tx *sql.Tx) error {
func upAddGenreTables(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to import multiple genres!")
_, err := tx.Exec(`
create table if not exists genre
@ -63,6 +64,6 @@ create table if not exists artist_genres
return forceFullRescan(tx)
}
func downAddGenreTables(tx *sql.Tx) error {
func downAddGenreTables(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddMediafileChannels, downAddMediafileChannels)
goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels)
}
func upAddMediafileChannels(tx *sql.Tx) error {
func upAddMediafileChannels(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add channels integer;
@ -25,6 +26,6 @@ create index if not exists media_file_channels
return forceFullRescan(tx)
}
func downAddMediafileChannels(tx *sql.Tx) error {
func downAddMediafileChannels(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddSmartPlaylist, downAddSmartPlaylist)
goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist)
}
func upAddSmartPlaylist(tx *sql.Tx) error {
func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table playlist
add column rules varchar null;
@ -32,6 +33,6 @@ create unique index playlist_fields_idx
return err
}
func downAddSmartPlaylist(tx *sql.Tx) error {
func downAddSmartPlaylist(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,6 +1,7 @@
package migrations
import (
"context"
"database/sql"
"strings"
@ -10,10 +11,10 @@ import (
)
func init() {
goose.AddMigration(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile)
goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile)
}
func upAddOrderTitleToMediaFile(tx *sql.Tx) error {
func upAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table main.media_file
add order_title varchar null collate NOCASE;
@ -56,6 +57,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
return rows.Err()
}
func downAddOrderTitleToMediaFile(tx *sql.Tx) error {
func downAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,6 +1,7 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/log"
@ -9,10 +10,10 @@ import (
)
func init() {
goose.AddMigration(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments)
goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments)
}
func upUnescapeLyricsAndComments(tx *sql.Tx) error {
func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
rows, err := tx.Query(`select id, comment, lyrics, title from media_file`)
if err != nil {
return err
@ -42,6 +43,6 @@ func upUnescapeLyricsAndComments(tx *sql.Tx) error {
return rows.Err()
}
func downUnescapeLyricsAndComments(tx *sql.Tx) error {
func downUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddUseridToPlaylist, downAddUseridToPlaylist)
goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist)
}
func upAddUseridToPlaylist(tx *sql.Tx) error {
func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table playlist_dg_tmp
(
@ -55,6 +56,6 @@ create index playlist_updated_at
return err
}
func downAddUseridToPlaylist(tx *sql.Tx) error {
func downAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex)
goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex)
}
func upAddAlphabeticalByArtistIndex(tx *sql.Tx) error {
func upAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create index album_alphabetical_by_artist
ON album(compilation, order_album_artist_name, order_album_name)
@ -18,6 +19,6 @@ create index album_alphabetical_by_artist
return err
}
func downAddAlphabeticalByArtistIndex(tx *sql.Tx) error {
func downAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,22 +1,23 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds)
goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds)
}
func upRemoveInvalidArtistIds(tx *sql.Tx) error {
func upRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id)
`)
return err
}
func downRemoveInvalidArtistIds(tx *sql.Tx) error {
func downRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId)
goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId)
}
func upAddMusicbrainzReleaseTrackId(tx *sql.Tx) error {
func upAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add mbz_release_track_id varchar(255);
@ -22,7 +23,7 @@ alter table media_file
return forceFullRescan(tx)
}
func downAddMusicbrainzReleaseTrackId(tx *sql.Tx) error {
func downAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddAlbumImagePaths, downAddAlbumImagePaths)
goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths)
}
func upAddAlbumImagePaths(tx *sql.Tx) error {
func upAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table main.album add image_files varchar;
`)
@ -21,6 +22,6 @@ alter table main.album add image_files varchar;
return forceFullRescan(tx)
}
func downAddAlbumImagePaths(tx *sql.Tx) error {
func downAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upRemoveCoverArtId, downRemoveCoverArtId)
goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId)
}
func upRemoveCoverArtId(tx *sql.Tx) error {
func upRemoveCoverArtId(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table album drop column cover_art_id;
alter table album rename column cover_art_path to embed_art_path
@ -22,6 +23,6 @@ alter table album rename column cover_art_path to embed_art_path
return forceFullRescan(tx)
}
func downRemoveCoverArtId(tx *sql.Tx) error {
func downRemoveCoverArtId(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,21 +1,22 @@
package migrations
import (
"context"
"database/sql"
"path/filepath"
"slices"
"strings"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/pressly/goose/v3"
"golang.org/x/exp/slices"
)
func init() {
goose.AddMigration(upAddAlbumPaths, downAddAlbumPaths)
goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths)
}
func upAddAlbumPaths(tx *sql.Tx) error {
func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`alter table album add paths varchar;`)
if err != nil {
return err
@ -62,6 +63,6 @@ func upAddAlbumPathsDirs(filePaths string) string {
return strings.Join(dirs, string(filepath.ListSeparator))
}
func downAddAlbumPaths(tx *sql.Tx) error {
func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,20 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upTouchPlaylists, downTouchPlaylists)
goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists)
}
func upTouchPlaylists(tx *sql.Tx) error {
func upTouchPlaylists(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`update playlist set updated_at = datetime('now');`)
return err
}
func downTouchPlaylists(tx *sql.Tx) error {
func downTouchPlaylists(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upCreateInternetRadio, downCreateInternetRadio)
goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio)
}
func upCreateInternetRadio(tx *sql.Tx) error {
func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table if not exists radio
(
@ -25,6 +26,6 @@ create table if not exists radio
return err
}
func downCreateInternetRadio(tx *sql.Tx) error {
func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddReplaygainMetadata, downAddReplaygainMetadata)
goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata)
}
func upAddReplaygainMetadata(tx *sql.Tx) error {
func upAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file add
rg_album_gain real;
@ -29,6 +30,6 @@ alter table media_file add
return forceFullRescan(tx)
}
func downAddReplaygainMetadata(tx *sql.Tx) error {
func downAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error {
return nil
}

Some files were not shown because too many files have changed in this diff Show More