Compare commits

..

917 Commits

Author SHA1 Message Date
Deluan
c7d7ec306e fix(mcp-agent): stream native mcp-server stderr to logs
The previous implementation buffered stderr from the native mcp-server process and only logged the full buffer content when the process exited. This prevented real-time viewing of logs from the server.

This change modifies the native process startup logic (`startProcess_locked`) to use `cmd.StderrPipe()` instead of assigning `cmd.Stderr` to a buffer. A separate goroutine is now launched within the process monitoring goroutine. This new goroutine uses a `bufio.Scanner` to continuously read lines from the stderr pipe and logs them using the Navidrome logger (`log.Info`) with an `[MCP-SERVER]` prefix.

This ensures logs from the native mcp-server appear in Navidrome's logs immediately as they are written.

(Note: Also includes update to McpServerPath constant to point to the native binary.)

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-19 20:53:10 -04:00
Deluan
bcc3643c81 chore: add debug logging to mcp-server
Added debug logging throughout the mcp-server components using the standard Go `log` package. All log messages are prefixed with `[MCP]` for easy identification.

This includes logging in: main function (startup, CLI execution, registration, serve loop), Tool handlers, Native and WASM fetcher implementations, Wikipedia, Wikidata, and DBpedia data fetching functions

Replaced previous `println` statements with `log.Println` or `log.Printf`.
2025-04-19 19:37:32 -04:00
Deluan
97b101685e perf: pre-compile WASM module for MCP agent
Modified the MCP agent constructor to pre-compile the WASM module when detected. This shifts the costly compilation step out of the first API request path.

The `MCPWasm` implementation now stores the `wazero.CompiledModule` provided by the constructor and uses it directly for instantiation via `runtime.InstantiateModule()` when the agent is first used or restarted. This significantly speeds up the initialization during the first request.

Updated tests and cleanup logic to accommodate the shared nature of the pre-compiled module.
2025-04-19 19:23:23 -04:00
Deluan
8660cb4fff refactor: centralize MCP agent method logic and cleanup comments
Centralized the argument preparation for GetArtistBiography and GetArtistURL within the main MCPAgent struct. Added callMCPTool to the mcpImplementation interface and removed the redundant GetArtist* methods from the native and WASM implementations.

Removed the embedded agents.Artist*Retriever interfaces from mcpImplementation as MCPAgent now directly fulfills these.

Also removed various redundant comments and leftover commented-out code from the agent, implementation, and test files.
2025-04-19 19:11:34 -04:00
Deluan
ae93e555c9 feat: refactor MCP agent to support native and WASM implementations
Refactored the MCPAgent to delegate core functionality to separate implementations for native process and WASM execution.

Introduced an `mcpImplementation` interface (`MCPNative`, `MCPWasm`) to abstract the underlying execution method. The main `MCPAgent` now holds an instance of this interface, selected by the `mcpConstructor` based on the `McpServerPath` (native executable or `.wasm` file).

Shared Wazero resources (runtime, cache, WASI, host functions) are now initialized once in the constructor and passed to the `MCPWasm` implementation, improving resource management and potentially startup performance for WASM modules.

Updated tests (`mcp_agent_test.go`) to use a new testing constructor (`NewAgentForTesting`) which injects a mock client into the appropriate implementation. Assertions were adjusted to reflect the refactored error handling and structure. Also updated the `McpServerPath` to use a relative path.
2025-04-19 19:05:15 -04:00
Deluan
2f71516dde fix: update MCP server path for agent initialization
Change the MCP server path in MCPAgent to point to the correct relative directory for the WASM file. This adjustment ensures proper initialization and access to the server resources, aligning with recent enhancements in the MCPAgent's handling of server types.
2025-04-19 18:43:50 -04:00
Deluan
73da7550d6 refactor: separate native and WASM process handling in MCPAgent
- Moved the native process handling logic from mcp_agent.go to a new file mcp_process_native.go for better organization.
- Introduced a new file mcp_host_functions.go to define and register host functions for WASM modules.
- Updated MCPAgent to ensure proper initialization and cleanup of both native and WASM processes, enhancing code clarity and maintainability.
- Added comments to clarify the purpose of changes and ensure future developers understand the structure.
2025-04-19 15:22:15 -04:00
Deluan
674129a34b fix: update MCP server path for agent initialization
Change the MCP server path in MCPAgent from a WASM file to a directory. This adjustment aligns with recent enhancements to the MCPAgent's handling of server types, ensuring proper initialization and access to the server resources.
2025-04-19 14:48:18 -04:00
Deluan
fb0714562d feat: grant filesystem access for WASM modules in MCPAgent
Enhance the MCPAgent's WASM module initialization by granting access to the host filesystem. This is necessary for DNS lookups and other operations that may depend on filesystem access. Added comments to highlight the security implications of this change and the need for potential restrictions in the future.
2025-04-19 14:46:54 -04:00
Deluan
6b89f7ab63 feat: integrate Wazero for WASM support in MCPAgent
Enhance MCPAgent to support both native executables and WASM modules using Wazero. This includes:
- Adding Wazero dependencies in go.mod and go.sum.
- Modifying MCPAgent to initialize a shared Wazero runtime and compile/load WASM modules.
- Implementing cleanup logic for WASM resources.
- Updating the process initialization to handle both native and WASM paths.

This change improves the flexibility of the MCPAgent in handling different server types.
2025-04-19 14:28:55 -04:00
Deluan
c548168503 fix: handle error messages returned in MCP tool response content
Modify callMCPTool helper to check if the text content returned by\nthe MCP server starts with 'handler returned an error:'.\n\nIf it does, log a warning and return agents.ErrNotFound, treating\nthe error signaled by the external tool as if the data was not found.\nAdded test cases to verify this behavior.
2025-04-19 14:01:51 -04:00
Deluan
8ebefe4065 refactor: DRY up MCPAgent implementation
Refactor the MCPAgent to reduce code duplication.\n\n- Consolidate GetArtistBiographyArgs and GetArtistURLArgs into a single\n  ArtistArgs struct.\n- Extract common logic (initialization check, locking, tool calling,\n  error handling, response validation) into a private callMCPTool helper method.\n- Simplify GetArtistBiography and GetArtistURL to delegate to callMCPTool.\n- Update tests to use the consolidated ArtistArgs struct.\n- Correct mutex locking in ensureClientInitialized to prevent race conditions.
2025-04-19 13:04:41 -04:00
Deluan
6e59060a01 fix: prevent double initialization of agents
Modify the createAgents function to call each agent constructor only once.\n\nPreviously, the constructor (init(ds)) was called first to check if the\nagent could be initialized, and then called again when appending the agent\nto the result slice. This caused unnecessary work and duplicate log messages.\n\nThe fix stores the result of the first call in the 'agent' variable and\nreuses this instance when appending, ensuring each constructor runs only once.
2025-04-19 13:01:41 -04:00
Deluan
be9e10db37 test: add mock-based tests for MCPAgent
Implement unit tests for MCPAgent using a mocked MCP client.\n\n- Define an mcpClient interface and a mock implementation in the test file.\n- Modify MCPAgent to use the interface and add an exported ClientOverride field\n  to allow injecting the mock during tests.\n- Export necessary constants and argument structs from the agent package.\n- Add test cases covering success, tool errors, empty responses, and pipe errors\n  for both GetArtistBiography and GetArtistURL.\n- Fix agent logic to handle empty TextContent in responses correctly.\n- Remove previous placeholder tests and unreliable initialization test.
2025-04-19 12:56:22 -04:00
Deluan
9c20520d59 feat: implement GetArtistURL in MCP agent
Add support for retrieving artist URLs via the MCP agent.\n\n- Implement the agents.ArtistURLRetriever interface.\n- Add the GetArtistURL method, which calls the 'get_artist_url'\n  tool on the external MCP server.\n- Define the necessary constants and argument struct.\n\nThis mirrors the existing implementation for GetArtistBiography, reusing\nthe persistent process and client logic.
2025-04-19 12:48:27 -04:00
Deluan
8b754a7c73 feat: implement MCP agent process auto-restart
Modify the MCPAgent to automatically attempt restarting the external\nserver process if it detects the process has exited.\n\n- Replaced sync.Once with mutex-protected checks (a.client == nil) to allow\n  re-initialization.\n- The monitoring goroutine now cleans up agent state (nils client, cmd, stdin)\n  upon process exit, signaling the need for restart.\n- ensureClientInitialized now attempts to start/initialize if the client is nil.\n- GetArtistBiography checks client validity again after locking to handle race\n  conditions where the process might die just after initialization check.
2025-04-19 12:45:41 -04:00
Deluan
8326a20eda refactor: keep MCP agent server process running
Refactor MCPAgent to maintain a persistent external server process.\n\nInstead of starting and stopping the MCP server for each request, the agent\nnow uses sync.Once for lazy initialization on the first call.\nIt stores the running exec.Cmd, stdio pipes, and the mcp.Client\nin the agent struct.\n\nA sync.Mutex protects concurrent access to the client/pipes.\nA goroutine monitors the process using cmd.Wait() and logs if it exits\nunexpectedly.\n\nThis avoids the overhead of process creation/destruction on every\nmetadata retrieval request.
2025-04-19 12:04:39 -04:00
Deluan
51567a0bdf feat: add proof-of-concept MCP agent
Add a new agent implementation MCPAgent in core/agents/mcp.\n\nThis agent interacts with an external Model Context Protocol (MCP) server\nusing the github.com/metoro-io/mcp-golang library via stdio.\n\nIt currently implements the ArtistBiographyRetriever interface to fetch\nbiographies by calling the 'get_artist_biography' tool on the MCP server.\nThe path to the MCP server executable is hardcoded for this PoC.\n\nIncludes basic Ginkgo test setup for the new agent.
2025-04-19 11:59:34 -04:00
Deluan
4944f8035a test: add tests for userName and AbsolutePath in core/common.go
Added Ginkgo/Gomega tests for userName and AbsolutePath functions in core/common.go. Tests cover normal and error cases, using existing mocks and helpers. This improves coverage and ensures correct behavior for user context extraction and library path resolution.
2025-04-18 11:53:47 -04:00
Ivan Pešić
0d5097d888 fix(ui): update Serbian translation (#3941) 2025-04-17 19:27:12 -04:00
Deluan Quintão
ed7ee3d9f8 fix(ui): always order album tracks by disc and track number (fixes #3720) (#3975)
* fix(ui): ensure album tracks are always ordered by disc and track number (fixes #3720)

* refactor(ui): remove obsolete release date grouping logic from SongDatagrid and AlbumSongs

* fix(ui): ensure correct album track ordering in context menu and play button

* fix: Update album sort to use album_id instead of release_date

* refactor: Adjust filters in PlayButton and AlbumContextMenu

* fix: correct typo in comment regarding participants in GetMissingAndMatching function

* fix: prevent visual separation of tracks on same disc

Removes the leftover `releaseDate` check from the `firstTracksOfDiscs` calculation in `SongDatagridBody`. This check caused unnecessary `DiscSubtitleRow` insertions when tracks on the same disc had different release dates, leading to an incorrect visual grouping that resembled a multi-disc layout.

This change ensures disc subtitles are only shown when the actual `discNumber` changes, correcting the UI presentation issue reported in issue #3720 after PR #3975.

* fix: remove remaining releaseDate references in SongDatagrid

Cleaned up leftover `releaseDate` references in `SongDatagrid.jsx`:

- Removed `releaseDate` parameter and usage from `handlePlaySubset` in `DiscSubtitleRow`.

- Removed `releaseDate` prop passed to `AlbumContextMenu` in `DiscSubtitleRow`.

- Removed `releaseDate` from the drag item data in `SongDatagridRow`.

- Removed `releaseDate` parameter and the corresponding `else` block from the `playSubset` function in `SongDatagridBody`.

This ensures the component consistently uses `discNumber` for grouping and playback actions initiated from the disc subtitle, fully resolving the inconsistencies related to issue #3720.
2025-04-17 19:23:53 -04:00
Deluan Quintão
74803bb43e fix(ui): update Russian, Turkish translations from POEditor (#3971)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-04-16 21:09:50 -04:00
marcbres
0159cf73e2 fix(ui): updated Catalan translations (#3973)
Co-authored-by: Marc Bres Gil <marc@helm>
2025-04-16 21:05:59 -04:00
Dongeun
ac1d51f9d0 fix(ui): update Chinese (Simplified) translations (#3938) 2025-04-16 21:05:26 -04:00
Thomas Johansen
91eb661db5 fix(ui): update Norwegian translation #3943 2025-04-16 21:04:10 -04:00
Guilherme Souza
524d508916 feat(ui): show sampleRate in song info dialog (#3960)
* feat(ui): show sampleRate in song info dialog

* npm run prettier --write
2025-04-12 20:52:47 -04:00
Deluan
a6f1f7b7e3 fix(server): albumtype in Smart Playlists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-11 23:53:16 -04:00
Deluan Quintão
49b8cfc261 fix(artwork): always select the coverArt of the first disc in multi-disc albums (#3950)
* feat(artwork): sort image files for consistent cover art selection

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(artwork): improve album cover art selection by considering disc numbers

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-11 23:39:57 -04:00
Deluan
bcea8b832a chore(deps): update Go version to 1.24.2 in go.mod 2025-04-11 23:18:00 -04:00
Deluan Quintão
58367afaea refactor: external_metadata -> external.Provider (#3903)
* tests for TopSongs

Signed-off-by: Deluan <deluan@navidrome.org>

* convert to Ginkgo

Signed-off-by: Deluan <deluan@navidrome.org>

* consolidate tests

Signed-off-by: Deluan <deluan@navidrome.org>

* rename external metadata -wip

Signed-off-by: Deluan <deluan@navidrome.org>

* rename external metadata to extdata.Provider

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor test helpers

Signed-off-by: Deluan <deluan@navidrome.org>

* remove reflection

Signed-off-by: Deluan <deluan@navidrome.org>

* use mock.Mock

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* fix

Signed-off-by: Deluan <deluan@navidrome.org>

* receive Agents interface in Provider constructor

Signed-off-by: Deluan <deluan@navidrome.org>

* use mock for Agents

Signed-off-by: Deluan <deluan@navidrome.org>

* tests for SimilarSongs

Signed-off-by: Deluan <deluan@navidrome.org>

* remove duplication

Signed-off-by: Deluan <deluan@navidrome.org>

* ArtistImage tests

Signed-off-by: Deluan <deluan@navidrome.org>

* AlbumImage tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix provider error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateAlbumInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateAlbumInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateArtistInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* clean up

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* fix test descriptions

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename extdata package to external

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 21:11:09 -04:00
Deluan
6b59f5f73a feat(ui): add genre and mood fields to AlbumSongs component
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 18:13:37 -04:00
Deluan Quintão
5f0c1e7387 chore(deps) upgrade Go dependencies, including golangci-lint (#3937)
* chore(deps): update Go dependencies

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): upgrade golangci-lint

Signed-off-by: Deluan <deluan@navidrome.org>

* build: upgrade golangci-lint-action to v7

Signed-off-by: Deluan <deluan@navidrome.org>

* go mod tidy

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-07 19:42:00 -03:00
Deluan Quintão
a057a680f1 fix(ui): update Greek, Esperanto, Polish, Russian, Turkish translations from POEditor (#3894)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-04-05 08:54:29 -03:00
Deluan Quintão
f9081bbe6b fix(server): first user created should be admin, when using reverse proxy (#3920)
Fix #3902

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-05 08:24:14 -03:00
Deluan Quintão
73eb0e254b feat(ui): add mood column to Album and Song list views (#3925)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-05 08:23:52 -03:00
Deluan Quintão
2b84c574ba fix: restore old date display/sort behaviour (#3862)
* fix(server): bring back legacy date mappings

Signed-off-by: Deluan <deluan@navidrome.org>

* reuse the mapDates logic in the legacyReleaseDate function

Signed-off-by: Deluan <deluan@navidrome.org>

* fix mappings

Signed-off-by: Deluan <deluan@navidrome.org>

* show original and release dates in album grid

Signed-off-by: Deluan <deluan@navidrome.org>

* fix tests based on new year mapping

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): prefer returning original_year over (recording) year
when sorting albums

Signed-off-by: Deluan <deluan@navidrome.org>

* fix case when we don't have originalYear

Signed-off-by: Deluan <deluan@navidrome.org>

* show all dates in album's info, and remove the recording date from the album page

Signed-off-by: Deluan <deluan@navidrome.org>

* better?

Signed-off-by: Deluan <deluan@navidrome.org>

* add snapshot tests for Album Details

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): sort order for getAlbumList?type=byYear

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-30 17:06:58 -04:00
Deluan
88f87e6c4f chore: replace album placeholder
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-30 13:41:32 -04:00
Deluan
cf100c4eb4 chore(subsonic): update snapshot tests to use version 1.16.1 2025-03-27 22:50:22 -04:00
Deluan Quintão
5ab345c83e chore(server): add more info to scrobble errors logs (#3889)
* chore(server): add more info to scrobble errors

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(server): add more info to scrobble errors

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(server): add more info to scrobble errors

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-27 18:57:06 -04:00
Deluan
46a2ec0ba1 feat(ui): hide absolute paths from regular users
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-25 20:05:24 -04:00
Deluan
3394580413 feat(ui): add Norwegian translation
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-25 17:43:25 -04:00
Michachatz
112ea281d9 feat(ui): add Greek translation (#3892)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-25 17:33:58 -04:00
Deluan Quintão
c837838d58 fix(ui): update French, Polish, Turkish translations from POEditor (#3834)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-03-24 17:52:03 -04:00
matteo00gm
9e9465567d fix(ui): update Italian translations (#3885) 2025-03-24 17:49:23 -04:00
Deluan
651ce163c7 fix(ui): sort playlist by album_artist, bpm and channels
fix #3878

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-24 16:41:54 -04:00
Deluan Quintão
55ce28b2c6 fix(bfr): force upgrade to read all folders. (#3871)
* chore(scanner): add trace logs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(bfr): force upgrade to read all folders. It was skipping folders for certain timezones

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-24 15:22:59 -04:00
Deluan
d331ee904b fix(ui): sort playlist by year
fix #3878

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-24 15:08:17 -04:00
Deluan
3a0ce6aafa fix(scanner): elapsed time for folder processing is wrong in the logs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-23 12:36:38 -04:00
Deluan
1806552ef6 chore: remove more outdated TODOs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-23 11:53:43 -04:00
Deluan
223e88d481 chore: remove some BFR-related TODOs that are not valid anymore
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-23 11:37:20 -04:00
Deluan Quintão
57e0f6d3ea feat(server): custom ArtistJoiner config (#3873)
* feat(server): custom ArtistJoiner config

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ui): organize ArtistLinkField, add tests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): use display artist

* feat(ui): use display artist

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-23 10:53:21 -04:00
Deluan
1c691ac0e6 feat(docker): automatically loads a navidrome.toml file from /data, if available
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 17:33:56 -04:00
Deluan
264d73d73e fix(server): don't break if the ND_CONFIGFILE does not exist
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 17:08:03 -04:00
Deluan
296259d781 feat(ui): show bitDepth in song info dialog
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 15:48:29 -04:00
Deluan
3f9d173495 fix(scanner): support ID3v2 embedded images in WAV files
Fix #3867

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 15:48:07 -04:00
Deluan
b386981b7f fix(scanner): better log message when AutoImportPlaylists is disabled
Fix #3861

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 15:08:26 -04:00
Deluan Quintão
be7cb59dc5 fix(scanner): allow disabling splitting with the Tags config option (#3869)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-22 12:34:35 -04:00
Nicolas Derive
63dc0e2062 fix(ui): update Français, reorder translation according to en.json template (#3839)
Update french translation and reorder the file the same way as the en.json template, making comparison easier.
2025-03-22 12:31:32 -04:00
Xabi
1e1dce92b6 fix(ui): update Basque translation (#3864)
* Update Basque localisation

added missing strings

* Update eu.json
2025-03-22 12:29:43 -04:00
Deluan
d78c6f6a04 fix(subsonic): ArtistID3 should contain list of AlbumID3
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-20 22:10:46 -04:00
Deluan Quintão
59ece40393 fix(server): better embedded artwork extraction with ffmpeg (#3860)
- `-map 0:v` selects all video streams from the input
- `-map -0:V` excludes all "main" video streams (capital V)

This combination effectively selects only the attached pictures

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-20 19:26:40 -04:00
Deluan
491210ac12 fix(scanner): ignore NaN ReplayGain values
Fix: https://github.com/navidrome/navidrome/issues/3858
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-20 12:42:09 -04:00
Deluan
cd552a55ef fix(scanner): pass datafolder and cachefolder to scanner subprocess
Fix #3831

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-19 22:15:20 -04:00
Deluan
ee2c2b19e9 fix(dockerfile): remove the healthcheck, it gives more headaches than benefits.
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-19 20:18:56 -04:00
Deluan
0147bb5f12 chore(deps): upgrade viper to 1.20.0, add tests for the supported config formats
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-18 19:16:47 -04:00
Rob Emery
1ed8930107 fix(msi): don't override custom ini config (#3836)
Previously addLine would add-or-update, resulting in the custom settings being overriden on upgrade. createLine will only add to the ini if the key doesn't already exist.
2025-03-18 18:23:04 -04:00
Deluan
e457f21306 chore(server): show square flag in resize artwork logs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-18 12:43:52 -04:00
Deluan Quintão
b04647309f chore(deps): upgrade to Go 1.24.1 (#3851)
* chore(deps): upgrade to Go 1.24.1

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): add reflex as go.mod tool

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): add wire as go.mod tool

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): add goimports as go.mod tool

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): add ginkgo as go.mod tool

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-17 21:08:10 -04:00
Deluan Quintão
2adb098f32 fix(scanner): fix displayArtist logic (#3835)
* fix displayArtist logic

Signed-off-by: Deluan <deluan@navidrome.org>

* remove unneeded value

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* Use first albumartist if it cannot figure out the display name

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-17 19:21:33 -04:00
Kendall Garner
212887214c fix(ui): minor icon inconsistencies and "no missing files" translation (#3837)
* chore(ui): Fix minor inconsistencies

1. The icons in the user menu are a mix of MUI and react-icons. Move them all to react-icons, and use a standard size (24px)
2. On missing files page, provide a custom Empty component that just removes 'yet'

* use RA's builtin support for custom empty message

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-03-16 19:39:19 -04:00
Deluan Quintão
beb768cd9c feat(server): add Role filters to albums (#3829)
* navidrome artist filtering

* address discord feedback

* perPage min 36

* various artist artist_id -> albumartist_id

* artist_id, role_id separate

* remove all ui changes I guess

* Add tests, check for possible SQL injection

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2025-03-14 21:43:52 -04:00
Kendall Garner
ed1109ddb2 fix(subsonic): fix albumCount in artists (#3827)
* only do subsonic instead

* make sure to actually populate response first

* navidrome artist filtering

* address discord feedback

* perPage min 36

* various artist artist_id -> albumartist_id

* artist_id, role_id separate

* remove all ui changes I guess

* Revert role filters

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-03-14 21:21:03 -04:00
Deluan
98808e4b6d docs(scanner): clarifies the purpose of the mappings.yaml file for regular users
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-14 19:32:26 -04:00
Deluan
422ba2284e chore(scanner): add logs to .ndignore processing
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-14 17:44:11 -04:00
Kendall Garner
938c3d44cc fix(scanner): restore setsubtitle as discsubtitle for non-WMA (#3821)
With old metadata, Disc Subtitle was one of `tsst`, `discsubtitle`, or `setsubtitle`.
With the updated, `setsubtitle` is only available for flac.
Update `mappings.yaml` to maintain prior behavior.
2025-03-14 07:01:07 -04:00
Deluan
2838ac36df feat(scanner): allow disabling tags with Tags.<tag>.Ignore=true
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-13 19:55:30 -04:00
Deluan
b952672877 fix(scanner): add back the Scanner.GenreSeparators as a deprecated option
This allows easy upgrade of containers in PikaPods

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-13 19:25:07 -04:00
Deluan Quintão
5c0b6fb9b7 fix(server): skip non-UTF encoding during the database migration. (#3803)
Fix #3787

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-13 07:10:45 -04:00
Deluan
5fb1db6031 fix(scanner): watcher not working with relative MusicFolder
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-12 18:13:22 -04:00
Deluan
226be78bf5 fix(scanner): full_text not being updated on scan
Fixes #3813

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-12 17:51:36 -04:00
Deluan
7c13878075 fix(subsonic): getRandomSongs with genre filter
fix https://github.com/dweymouth/supersonic/issues/577

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-12 17:35:06 -04:00
Rodrigo Iglesias
0bb4b881e9 fix(ui): update Español translation (#3805)
Corrected "aletorio" and added some more translations
2025-03-11 20:42:09 -04:00
Deluan Quintão
70f536e04d fix(ui): skip missing files in bulk operations (#3807)
* fix(ui): skip missing files when adding to playqueue

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): skip missing files when adding to playlists

* fix(ui): skip missing files when shuffling songs

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-11 20:19:46 -04:00
Deluan Quintão
2a15a217de fix(server): db migration does not work for MusicFolders ending with a trailing slash. (#3797)
* fix(server): db migration was not working for MusicFolders ending with a trailing slash.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): db migration for relative paths

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-11 10:09:09 -04:00
Kendall Garner
a28462a7ab fix(ui): fix make dev (#3795)
1. For some bizarre reason, importing inflection by itself is undefined. But you can import specific functions
2. Per https://github.com/vite-pwa/vite-plugin-pwa/issues/419, `type: 'module',` is only for non-chromium browsers
2025-03-10 14:50:16 -04:00
Deluan
5c67297dce fix(server): panic when logging tag type. Fix #3790
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-10 07:14:17 -04:00
Deluan Quintão
365df5220b fix(server): db migration not working when MusicFolder is a relative path (#3766)
* fix(server): db migration not working when MusicFolder is a relative path

Signed-off-by: Deluan <deluan@navidrome.org>

* remove todo

Signed-off-by: Deluan <deluan@navidrome.org>

* fix migration of paths in Windows

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-09 19:14:29 -04:00
Deluan Quintão
b2b5c00331 fix(ui): update Finnish, Hungarian, Russian, Ukrainian translations from POEditor (#3780)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-03-09 18:22:20 -04:00
Deluan
ee18489b85 fix(subsonic): don't return empty disctitles for a single disc album
See https://support.symfonium.app/t/hide-disc-header-for-albums-with-only-1-disc/6877/1

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-09 17:22:41 -04:00
Deluan
57d3be8604 feat(subsonic): rename AppendSubtitle conf to Subsonic.AppendSubtitle, for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-08 19:02:29 -05:00
Deluan
0d42b9a4a5 chore(deps): bump more JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-07 20:07:15 -05:00
Deluan
a1a6047c37 chore(deps): bump Vite version
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-07 19:59:35 -05:00
Deluan
2171c44503 chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-07 19:47:08 -05:00
Deluan
fac01ccecb chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-07 19:36:46 -05:00
Deluan
98a6819390 fix(ui): disable bulk action buttons if transcoding edit is disabled
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-07 18:01:49 -05:00
Deluan
4156602158 build(ci): show English names for changed languages in POEditor PRs
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-07 12:12:44 -05:00
Deluan
21a5528f5e feat(server): deprecate Scanner.GroupAlbumReleases config option
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-06 23:57:47 -05:00
Deluan
31e003e6f3 feat(ui): use webp for login backgrounds
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-06 23:32:52 -05:00
ChekeredList71
e467e32c06 fix(ui): updated Hungarian translation for BFR (#3773)
* Hungarian translation for v0.54.1 done

* Hungarian translation for v0.54.1 done

* Updated Hugarian translation

* Updated Hugarian translation

---------

Co-authored-by: ChekeredList71 <null@example.com>
Co-authored-by: ChekeredList71 <ads@asd.com>
2025-03-06 22:41:45 -05:00
Kendall Garner
36ed880e61 fix(scanner): always refresh folder image time when adding first image (#3764)
* fix(scanner): Always refresh folder image time when adding first image

Currently, the `images_updated_at` field is only set to the image modification time.
However, in cases where a new image is added _and_ said image is older than the folder mod time, the field is not updated properly.

In this the case where `images_updated_at` is null (no images were ever added) and a new images is found, use the folder modification time instead of image modification time.

**Note**, this doesn't handle cases such as replacing a newer image with an older one.

* simplify image update at

* we don't want to set imagesUpdatedAt when there's no images in the folder

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-03-06 22:16:37 -05:00
Deluan
1c192d8a6d fix(ui): replace bulk "delete" label with "remove" in playlists
Fix #3525

Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-06 07:54:59 -05:00
Kendall Garner
5869f7caaf feat(subsonic): set sortName for OS AlbumList (#3776)
* feat(subsonic): Set SortName for OS AlbumList, test to JSON/XML

* albumlist2, star2 updated properly

* fix(subsonic): add sort or order name based on config

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-03-05 22:52:15 -05:00
Deluan
8732fc7226 fix(server): change log level for some unimportant messages
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-05 20:54:06 -05:00
Deluan
0372339e1b fix(server): only build core.Agents once
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-05 14:18:27 -08:00
Deluan
a04167672c fix(server): remove misleading "Agent not available" warning.
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-05 14:11:44 -08:00
Deluan
dc4e091622 feat(server): make appending subtitle to song title configurable
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-05 12:36:09 -08:00
Deluan
8ab2a11d22 feat(server): group Subsonic config options together
Signed-off-by: Deluan <deluan@navidrome.org>
2025-03-05 12:29:30 -08:00
Deluan
637c909e93 feat(server): removed GenreSeparator, replaced with Tag.Genre.Split
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-28 15:36:21 -10:00
Deluan
453873fa26 feat(insights): send scanner options
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-28 15:36:21 -10:00
Deluan
de37e0f720 feat(server): rename ScanSchedule conf to Scanner.Schedule, for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-28 15:36:21 -10:00
Deluan
f3cb85cb0d feat(server): warn users of ffmpeg extractor that it is not available anymore
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-28 12:39:30 -08:00
Deluan Quintão
0c4c223127 fix(server): import absolute paths in m3u (#3756)
* fix(server): import playlists with absolute paths

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): optimize playlist import

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): add test with multiple libraries

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): refactor

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-26 22:26:38 -05:00
Deluan Quintão
3892f70c35 fix(ui): update Deutsch, Español, Euskara, Galego, Bahasa Indonesia, 日本語, Português, Pусский, Türkçe translations from POEditor (#3681)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-02-26 22:20:48 -05:00
Deluan Quintão
1468a56808 fix(server): reduce SQLite "database busy" errors (#3760)
* fix(scanner): remove transactions where they are not strictly needed

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): force setStar transaction to start as IMMEDIATE

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): encapsulated way to upgrade tx to write mode

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): use tx immediate for some playlist endpoints

Signed-off-by: Deluan <deluan@navidrome.org>

* make more transactions immediate (#3759)

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2025-02-26 22:01:49 -05:00
Deluan
d6ec52b9d4 fix(subsonic): check errors before setting headers for getCoverArt
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-25 08:22:38 -05:00
Deluan
5fa19f9cfa chore(server): add logs to begin/end transaction
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-24 19:13:42 -05:00
Deluan
15a3d2ca66 fix(server): disallow search engine crawlers in robots.txt
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-23 22:01:01 -05:00
Kendall Garner
efab198d4a test(server): validate play tracker participants, scrobble buffer (#3752)
* test(server): validate play tracker participants, scrobble buffer

* tests(server): nit: remove duplicated tests and small cleanups

Signed-off-by: Deluan <deluan@navidrome.org>

* tests(server): nit: replace panics with assertions

Signed-off-by: Deluan <deluan@navidrome.org>

* just use random ids, and store it instead

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-02-23 21:52:51 -05:00
Deluan
5ad9f546b2 fix(server): role filters in Smart Playlists.
See https://github.com/navidrome/navidrome/discussions/3676#discussioncomment-12286960

Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-23 14:08:53 -05:00
Deluan
20297c2aea fix(server): send artist mbids when scrobbling to ListenBrainz
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-23 13:30:39 -05:00
Kendall Garner
f6eee65955 feat(ui): Show performer subrole(s) where possible (#3747)
* feat(ui): Show performer subrole(s) where possible

* nit: simplify subrole formatting

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-02-22 12:05:19 -05:00
Kendall Garner
aee19e747c feat(ui): Improve Artist Album pagination (#3748)
* feat(ui): Improve Artist Album pagination

- use maximum of albumartist/artist credits for determining pagination
- reduce default maxPerPage considerably. This gives values of 36/72/108 at largest size

* enable pagination when over 90

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-02-22 09:31:20 -05:00
Deluan
f34f15ba1c feat(ui): make need for refresh more visible when upgrading server
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-21 18:15:25 -05:00
Deluan
74348a340f feat(server): new option to set the default for ReportRealPath on new players
Implements #3653

Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-20 22:24:09 -05:00
Deluan
09ae41a2da sec(subsonic): authentication bypass in Subsonic API with non-existent username
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-20 20:14:19 -05:00
Deluan
70487a09f4 fix(ui): paginate albums in artist page when needed
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-20 19:21:01 -05:00
Deluan
d4147c2330 fix(scanner): improve refresh artists stats query
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-20 14:55:45 -05:00
Deluan
dd4802c0c6 fix(ui): remove unused term
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-19 22:38:09 -05:00
Deluan
efed7f1b40 chore(deps): bump go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2025-02-19 21:15:35 -05:00
Xabi
6cc95d53a9 fix(ui): update Basque translation (#3666) 2025-02-19 21:01:27 -05:00
Deluan Quintão
c795bcfcf7 feat(bfr): Big Refactor: new scanner, lots of new fields and tags, improvements and DB schema changes (#2709)
* fix(server): more race conditions when updating artist/album from external sources

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scanner): add .gitignore syntax to .ndignore. Resolves #1394

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): null

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): pass configfile option to child process

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): resume interrupted fullScans

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): remove old scanner code

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): rename old metadata package

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): move old metadata package

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: tests

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): update Go to 1.23.4

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: logs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(test):

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: log level

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: remove log message

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add config for scanner watcher

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: children playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: replace `interface{}` with `any`

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: smart playlists with genres

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: allow any tags in smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: artist names in playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: smart playlist's sort by tags

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): use generic JSONArray for OS arrays

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): use https in test

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add releaseTypes to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add recordLabels to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic): rename JSONArray to Array

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to AlbumID3

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to Child

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(scanner): do not pre-populate smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): implement a simplified version of ArtistID3.

See https://github.com/opensubsonic/open-subsonic-api/discussions/120

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add artists to album child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add contributors to mediafile Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add albumArtists to mediafile Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add displayArtist and displayAlbumArtist

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add displayComposer to Child

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add roles to ArtistID3

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): use " • " separator for displayComposer

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor:

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic):

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): respect `PreferSortTags` config option

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(subsonic):

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: optimize purging non-unused tags

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: don't run 'refresh artist stats' concurrently with other transactions

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor:

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: log message

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add Scanner.ScanOnStartup config option, default true

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: better json parsing error msg when importing NSPs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't update album's imported_time when updating external_metadata

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: handle interrupted scans and full scans after migrations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: run `analyze` when migration requires a full rescan

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: run `PRAGMA optimize` at the end of the scan

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't update artist's updated_at when updating external_metadata

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: handle multiple artists and roles in smart playlists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): dim missing tracks

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album missing logic

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: error encoding in gob

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: separate warnings from errors

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: mark albums as missing if they were contained in a deleted folder

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: add participant names to media_file and album tables

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: use participations in criteria, instead of m2m relationship

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename participations to participants

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add moods to album child

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: albumartist role case

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scanner): run scanner as an external process by default

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): show albumArtist names

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): dim out missing albums

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: flaky test

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): scrobble buffer mapping. fix #3583

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: more participations renaming

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: listenbrainz scrobbling

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: send release_group_mbid to listenbrainz

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): implement OpenSubsonic explicitStatus field (#3597)

* feat: implement OpenSubsonic explicitStatus field

* fix(subsonic): fix failing snapshot tests

* refactor: create helper for setting explicitStatus

* fix: store smaller values for explicit-status on database

* test: ToAlbum explicitStatus

* refactor: rename explicitStatus helper function

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>

* fix: handle album and track tags in the DB based on the mappings.yaml file

Signed-off-by: Deluan <deluan@navidrome.org>

* save similar artists as JSONB

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: getAlbumList byGenre

Signed-off-by: Deluan <deluan@navidrome.org>

* detect changes in PID configuration

Signed-off-by: Deluan <deluan@navidrome.org>

* set default album PID to legacy_pid

Signed-off-by: Deluan <deluan@navidrome.org>

* fix tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix SIGSEGV

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't lose album stars/ratings when migrating

Signed-off-by: Deluan <deluan@navidrome.org>

* store full PID conf in properties

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: keep album annotations when changing PID.Album config

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: reassign album annotations

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: use (display) albumArtist and add links to each artist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: not showing albums by albumartist

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: error msgs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: hide PID from Native API

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album cover art resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: trim participant names

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: reduce watcher log spam

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: panic when initializing the watcher

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: various artists

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't store empty lyrics in the DB

Signed-off-by: Deluan <deluan@navidrome.org>

* remove unused methods

Signed-off-by: Deluan <deluan@navidrome.org>

* drop full_text indexes, as they are not being used by SQLite

Signed-off-by: Deluan <deluan@navidrome.org>

* keep album created_at when upgrading

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): null pointer

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album artwork cache

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't expose missing files in Subsonic API

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: searchable interface

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: filter out missing items from subsonic search

* fix: filter out missing items from playlists

* fix: filter out missing items from shares

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): add filter by artist role

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): only return albumartists in getIndexes and getArtists endpoints

Signed-off-by: Deluan <deluan@navidrome.org>

* sort roles alphabetically

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: artist playcounts

Signed-off-by: Deluan <deluan@navidrome.org>

* change default Album PID conf

Signed-off-by: Deluan <deluan@navidrome.org>

* fix albumartist link when it does not match any albumartists values

Signed-off-by: Deluan <deluan@navidrome.org>

* fix `Ignoring filter not whitelisted` (role) message

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: trim any names/titles being imported

Signed-off-by: Deluan <deluan@navidrome.org>

* remove unused genre code

Signed-off-by: Deluan <deluan@navidrome.org>

* serialize calls to Last.fm's getArtist

Signed-off-by: Deluan <deluan@navidrome.org>

xxx

Signed-off-by: Deluan <deluan@navidrome.org>

* add counters to genres

Signed-off-by: Deluan <deluan@navidrome.org>

* nit: fix migration `notice` message

Signed-off-by: Deluan <deluan@navidrome.org>

* optimize similar artists query

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: last.fm.getInfo when mbid does not exist

Signed-off-by: Deluan <deluan@navidrome.org>

* ui only show missing items for admins

Signed-off-by: Deluan <deluan@navidrome.org>

* don't allow interaction with missing items

Signed-off-by: Deluan <deluan@navidrome.org>

* Add Missing Files view (WIP)

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: merged tag_counts into tag table

Signed-off-by: Deluan <deluan@navidrome.org>

* add option to completely disable automatic scanner

Signed-off-by: Deluan <deluan@navidrome.org>

* add delete missing files functionality

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: playlists not showing for regular users

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce updateLastAccess frequency to once every minute

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce update player frequency to once every minute

Signed-off-by: Deluan <deluan@navidrome.org>

* add timeout when updating player

Signed-off-by: Deluan <deluan@navidrome.org>

* remove dead code

Signed-off-by: Deluan <deluan@navidrome.org>

* fix duplicated roles in stats

Signed-off-by: Deluan <deluan@navidrome.org>

* add `; ` to artist splitters

Signed-off-by: Deluan <deluan@navidrome.org>

* fix stats query

Signed-off-by: Deluan <deluan@navidrome.org>

* more logs

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: support legacy clients (DSub) by removing OpenSubsonic extra fields - WIP

Signed-off-by: Deluan <deluan@navidrome.org>

* add record label filter

Signed-off-by: Deluan <deluan@navidrome.org>

* add release type filter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix purgeUnused tags

Signed-off-by: Deluan <deluan@navidrome.org>

* add grouping filter to albums

Signed-off-by: Deluan <deluan@navidrome.org>

* allow any album tags to be used in as filters in the API

Signed-off-by: Deluan <deluan@navidrome.org>

* remove empty tags from album info

Signed-off-by: Deluan <deluan@navidrome.org>

* comments in the migration

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: Cannot read properties of undefined

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: listenbrainz scrobbling (#3640)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: remove duplicated tag values

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: don't ignore the taglib folder!

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: show track subtitle tag

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: show artists stats based on selected role

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: inspect

Signed-off-by: Deluan <deluan@navidrome.org>

* add media type to album info/filters

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: change format of subtitle in the UI

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: subtitle in Subsonic API and search

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: subtitle in UI's player

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: split strings should be case-insensitive

Signed-off-by: Deluan <deluan@navidrome.org>

* disable ScanSchedule

Signed-off-by: Deluan <deluan@navidrome.org>

* increase default sessiontimeout

Signed-off-by: Deluan <deluan@navidrome.org>

* add sqlite command line tool to docker image

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: resources override

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: album PID conf

Signed-off-by: Deluan <deluan@navidrome.org>

* change migration to mark current artists as albumArtists

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): Allow filtering on multiple genres (#3679)

* feat(ui): Allow filtering on multiple genres

Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Signed-off-by: Deluan <deluan@navidrome.org>

* add multi-genre filter in Album list

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Henrik Nordvik <henrikno@gmail.com>

* add more multi-valued tag filters to Album and Song views

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): unselect missing files after removing

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): song filter

Signed-off-by: Deluan <deluan@navidrome.org>

* fix sharing tracks. fix #3687

Signed-off-by: Deluan <deluan@navidrome.org>

* use rowids when using search for sync (ex: Symfonium)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Report Real Paths" option for subsonic clients

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Report Real Paths" option for subsonic clients for search

Signed-off-by: Deluan <deluan@navidrome.org>

* add libraryPath to Native API /songs endpoint

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(subsonic): add album version

Signed-off-by: Deluan <deluan@navidrome.org>

* made all tags lowercase as they are case-insensitive anyways.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(ui): Show full paths, extended properties for album/song (#3691)

* feat(ui): Show full paths, extended properties for album/song

- uses library path + os separator + path
- show participants (album/song) and tags (song)
- make album/participant clickable in show info

* add source to path

* fix pathSeparator in UI

Signed-off-by: Deluan <deluan@navidrome.org>

* fix local artist artwork (#3695)

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: parse vorbis performers

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: clean function into smaller functions

Signed-off-by: Deluan <deluan@navidrome.org>

* fix translations for en and pt

Signed-off-by: Deluan <deluan@navidrome.org>

* add trace log to show annotations reassignment

Signed-off-by: Deluan <deluan@navidrome.org>

* add trace log to show annotations reassignment

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: allow performers without instrument/subrole

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: metadata clean function again

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: optimize split function

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: split function is now a method of TagConf

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: humanize Artist total size

Signed-off-by: Deluan <deluan@navidrome.org>

* add album version to album details

Signed-off-by: Deluan <deluan@navidrome.org>

* don't display album-level tags in SongInfo

Signed-off-by: Deluan <deluan@navidrome.org>

* fix genre clicking in Album Page

Signed-off-by: Deluan <deluan@navidrome.org>

* don't use mbids in Last.fm api calls.

From https://discord.com/channels/671335427726114836/704303730660737113/1337574018143879248:

With MBID:
```
GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&mbid=a41ac10f-0a56-4672-9161-b83f9b223559&method=artist.getInfo

{
artist: {
name: "Bee Gees",
mbid: "bf0f7e29-dfe1-416c-b5c6-f9ebc19ea810",
url: "https://www.last.fm/music/Bee+Gees",
}
```

Without MBID:
```
GET https://ws.audioscrobbler.com/2.0/?api_key=XXXX&artist=Van+Morrison&format=json&lang=en&method=artist.getInfo

{
artist: {
name: "Van Morrison",
mbid: "a41ac10f-0a56-4672-9161-b83f9b223559",
url: "https://www.last.fm/music/Van+Morrison",
}
```

Signed-off-by: Deluan <deluan@navidrome.org>

* better logging for when the artist folder is not found

Signed-off-by: Deluan <deluan@navidrome.org>

* fix various issues with artist image resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* hide "Additional Tags" header if there are none.

Signed-off-by: Deluan <deluan@navidrome.org>

* simplify tag rendering

Signed-off-by: Deluan <deluan@navidrome.org>

* enhance logging for artist folder detection

Signed-off-by: Deluan <deluan@navidrome.org>

* make folderID consistent for relative and absolute folderPaths

Signed-off-by: Deluan <deluan@navidrome.org>

* handle more folder paths scenarios

Signed-off-by: Deluan <deluan@navidrome.org>

* filter out other roles when SubsonicArtistParticipations = true

Signed-off-by: Deluan <deluan@navidrome.org>

* fix "Cannot read properties of undefined"

Signed-off-by: Deluan <deluan@navidrome.org>

* fix lyrics and comments being truncated (#3701)

* fix lyrics and comments being truncated

* specifically test for lyrics and comment length

* reorder assertions

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>

* fix(server): Expose library_path for playlist (#3705)

Allows showing absolute path for UI, and makes "report real path" work for playlists (Subsonic)

* fix BFR on Windows (#3704)

* fix potential reflected cross-site scripting vulnerability

Signed-off-by: Deluan <deluan@navidrome.org>

* hack to make it work on Windows

* ignore windows executables

* try fixing the pipeline

Signed-off-by: Deluan <deluan@navidrome.org>

* allow MusicFolder in other drives

* move windows local drive logic to local storage implementation

---------

Signed-off-by: Deluan <deluan@navidrome.org>

* increase pagination sizes for missing files

Signed-off-by: Deluan <deluan@navidrome.org>

* reduce level of "already scanning" watcher log message

Signed-off-by: Deluan <deluan@navidrome.org>

* only count folders with audio files in it

See https://github.com/navidrome/navidrome/discussions/3676#discussioncomment-11990930

Signed-off-by: Deluan <deluan@navidrome.org>

* add album version and catalog number to search

Signed-off-by: Deluan <deluan@navidrome.org>

* add `organization` alias for `recordlabel`

Signed-off-by: Deluan <deluan@navidrome.org>

* remove mbid from Last.fm agent

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: support inspect in ui (#3726)

* inspect in ui

* address round 1

* add catalogNum to AlbumInfo

Signed-off-by: Deluan <deluan@navidrome.org>

* remove dependency on metadata_old (deprecated) package

Signed-off-by: Deluan <deluan@navidrome.org>

* add `RawTags` to model

Signed-off-by: Deluan <deluan@navidrome.org>

* support parsing MBIDs for roles (from the https://github.com/kgarner7/picard-all-mbids plugin) (#3698)


* parse standard roles, vorbis/m4a work for now

* fix djmixer

* working roles, use DJ-mix

* add performers to file

* map mbids

* add a few more tests

* add test

Signed-off-by: Deluan <deluan@navidrome.org>

* try to simplify the performers logic

Signed-off-by: Deluan <deluan@navidrome.org>

* stylistic changes

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>

* remove param mutation

Signed-off-by: Deluan <deluan@navidrome.org>

* run automated SQLite optimizations

Signed-off-by: Deluan <deluan@navidrome.org>

* fix playlists import/export on Windows

* fix import playlists

* fix export playlists

* better handling of Windows volumes

Signed-off-by: Deluan <deluan@navidrome.org>

* handle more album ID reassignments

Signed-off-by: Deluan <deluan@navidrome.org>

* allow adding/overriding tags in the config file

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): Fix playlist track id, handle missing tracks better (#3734)

- Use `mediaFileId` instead of `id` for playlist tracks
- Only fetch if the file is not missing
- If extractor fails to get the file, also error (rather than panic)

* optimize DB after each scan.

Signed-off-by: Deluan <deluan@navidrome.org>

* remove sortable from AlbumSongs columns

Signed-off-by: Deluan <deluan@navidrome.org>

* simplify query to get missing tracks

Signed-off-by: Deluan <deluan@navidrome.org>

* mark Scanner.Extractor as deprecated

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Signed-off-by: Henrik Nordvik <henrikno@gmail.com>
Co-authored-by: Caio Cotts <caio@cotts.com.br>
Co-authored-by: Henrik Nordvik <henrikno@gmail.com>
Co-authored-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
2025-02-19 20:35:17 -05:00
RTapeLoadingError
46a963a02a fix(ui): update Spanish translation (#3682)
Disambiguation for:
"recentlyAdded": "Añadidos recientemente",
"recentlyPlayed": "Reproducidos recientemente"
They share the same label: "Recientes".
2025-02-01 13:07:41 -05:00
Matvei Stefarov
195ae56001 fix(ui) Update Russian translation (#3678)
* fix(ui): Update Russian translations

- Adds missing strings added in the past couple releases
- Fixes a few confusing translations in the "share" section

* Add missing comma
2025-01-30 20:17:16 -05:00
Deluan Quintão
f9db449e7e fix(ui): update ไทย translations from POEditor (#3662)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-01-24 18:11:54 -05:00
Deluan
657fe11f53 fix: remove Access-Control-Allow-Origin. closes #3660
Signed-off-by: Deluan <deluan@navidrome.org>
2025-01-22 18:24:11 -05:00
Deluan Quintão
47e3fdb1b8 fix(server): do not try to validate credentials if the request is canceled (#3650)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-01-16 20:32:11 -05:00
Deluan Quintão
c37583fa9f feat(server): create M3Us from shares (#3652) 2025-01-16 20:26:16 -05:00
Deluan
9d86f63f15 fix(server): add logs to public image endpoint
Signed-off-by: Deluan <deluan@navidrome.org>
2025-01-15 08:47:47 -05:00
Deluan Quintão
73ccfbd839 fix(ui): update Türkçe translations from POEditor (#3636)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-01-13 18:07:45 -05:00
Kendall Garner
920fd53e58 fix(ui): remove index.html from service worker cache after creating admin user (#3642) 2025-01-12 18:32:02 -05:00
Kendall Garner
3179966270 fix(metrics): write system metrics on start (#3641)
* fix(metrics): write system metrics on start

* add broken basic auth test

* refactor: simplify Prometheus instantiation

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: basic authentication

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move magic strings to constants

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: simplify prometheus http handler

Signed-off-by: Deluan <deluan@navidrome.org>

* add artist metadata to aggregrate sql

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-01-11 21:02:36 -05:00
Deluan
537e2fc033 chore(deps): bump go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2025-01-09 22:27:59 -05:00
ChekeredList71
f1478d40f5 fix(ui): fix for typo in hu.json (#3635)
* Hungarian translation for v0.54.1 done

* Hungarian translation for v0.54.1 done

* Fix typo in hu.json

`metrikákat` was mistyped as `metrikükat`

---------

Co-authored-by: ChekeredList71 <null@example.com>
2025-01-09 18:30:53 -05:00
Deluan
beff1afad7 fix(subsonic): make Share's lastVisited optional
Signed-off-by: Deluan <deluan@navidrome.org>
2025-01-09 16:10:53 -05:00
Deluan
ba2623e3f1 feat(server): add more logs to backup
Signed-off-by: Deluan <deluan@navidrome.org>
2025-01-09 13:25:07 -05:00
Kendall Garner
d60e83176c feat(cli): support getting playlists via cli (#3634)
* feat(cli): support getting playlists via cli

* address initial nit

* use csv writer and csv instead
2025-01-09 12:01:37 -05:00
Deluan
acce3c97d5 fix(release): make binaries executable before packaging
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-29 23:43:57 -03:00
Deluan Quintão
734eb30ac5 fix(ui): update Suomi, Polski, Türkçe translations from POEditor (#3592)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2024-12-28 20:58:08 -05:00
Deluan
1eedee9086 fix(insights): add more linux fs types
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-27 12:13:15 -05:00
Kendall Garner
51eed74a0e fix(release): change owner of cache to Navidrome user (#3599) 2024-12-26 22:13:13 -05:00
Deluan
3942275689 chore(deps): bump github.com/andybalholm/cascadia from 1.3.2 to 1.3.3
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-25 17:49:42 -05:00
Deluan
98b038c1fb chore(deps): upgrade golang.org/x/net (CVE-2024-45338)
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-25 17:16:05 -05:00
Kendall Garner
f0302525a7 fix(server): use cancellable context instead of Sleep for initial insights delay (#3593)
* bugfix(server): use cancellable contet instead of sleep for initial insights delay

* fix(server): initial delay time

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-12-24 17:35:19 -05:00
Deluan Quintão
0299e488b5 fix(server): backup and restore issues from the cli (#3579)
* fix(server): backup not working from cli

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(server): make backup-file required for restore

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-22 16:41:40 -05:00
whorfin
630c304080 fix(server): typo in backup prune message (#3582)
probably a copypasta oops
2024-12-22 16:38:59 -05:00
Deluan
0bebd396df build(ci): use the head commit sha in PR versions
Ref: https://stackoverflow.com/a/68068674
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-21 20:21:13 -05:00
Deluan
0b18489327 build(poeditor): change commit message for translation update PRs
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-21 17:54:20 -05:00
Deluan Quintão
8880f67035 fix(ui): update Español, Français, Svenska translations from POEditor (#3576)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2024-12-21 17:52:10 -05:00
Deluan
99dfb832eb fix(insights): get Windows version
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-21 14:54:14 -05:00
Deluan
51c16aa69f chore: add PikaPods to release notes
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-21 14:39:34 -05:00
ChekeredList71
972229d1e8 fix(ui): update Hungarian translation (#3574)
* Hungarian translation for v0.54.1 done

* Hungarian translation for v0.54.1 done

---------

Co-authored-by: ChekeredList71 <null@example.com>
2024-12-21 14:26:22 -05:00
Deluan
c8f174ea84 fix(server): change log level for some last.fm warnings
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-21 13:39:39 -05:00
Deluan Quintão
d4dc8180a2 build(ci): fix release version label and package names (#3573)
* fix(ci): fix snapshot label

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ci): fix package names

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-21 13:36:22 -05:00
Deluan Quintão
851f54ea57 fix(ci): fix linux packages upload (#3569)
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-20 23:38:28 -05:00
Deluan Quintão
72a0f59be3 fix(ui): update translations from POEditor (#3568)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2024-12-20 18:48:22 -05:00
Deluan Quintão
c11fd9ce28 feat(ci): add updated language names to the POEditor PR title (#3566)
* refactor(ci): add updated languages to the POEditor PR title

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(ci): add an author to the PR

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-20 18:46:00 -05:00
Deluan
3e47819f7a fix(server): reduce album placeholder image size by converting it to webp
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 18:42:31 -05:00
Deluan
906ac635c2 fix(insights): check if running in a container
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 18:05:33 -05:00
Deluan
6bc4c0317f fix(insights): better status
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 17:21:08 -05:00
Deluan
04f296cc73 fix(ui): show last.fm api-key missing in a FormHelperText
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 16:59:26 -05:00
Dany Marcoux
21dd04cb7d docs: set org.opencontainers.image.source label in Dockerfile (#3564)
As documented in the OCI Image Format,
org.opencontainers.image.source[1] identifies an image's source
repository. This is purely for documentation purposes. It does however
help tools such as Renovate[2] to find the changelogs when a new
Navidrome version is released. The changelogs would then be included in
the PR Renovate creates.

[1]: 5325ec4885/annotations.md (L24)
[2]: https://docs.renovatebot.com/modules/datasource/docker/#description

Signed-off-by: Dany Marcoux <git@dmarcoux.com>
2024-12-19 16:56:33 -05:00
Deluan Quintão
2d8507cfd7 fix(ui): don't hide Last.fm scrobble switch (#3561)
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 09:08:28 -05:00
Deluan Quintão
6c11649b06 fix(insights): fix issues and improve reports (#3558)
* fix(insights): show error whn reading library counts

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): wait 30 mins before send first report

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): send number of active players, grouped by client type

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): disable reports when running in dev mode

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): add Dockerfile to the docker build, to avoid `vcs.modified=true`

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): add more linux fs types

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): need admin permissions to retrieve library counts

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): dev flag to disable player insights

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-18 20:37:35 -05:00
Caio Cotts
4f8cd5307c fix(ui): fix play queue for play button and context menus (#3559) 2024-12-18 17:57:42 -05:00
York
32afe9698c fix(ui): completed the translation of zh-Hant and zh-Hans (#3450)
* Completed the translation of zh-Hant and zh-Hans

* Update translation terms in zh-Hans and zh-Hant files

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-12-17 17:56:27 -05:00
Deluan Quintão
3e7c4b6f70 fix(ui): update Turkish, Galician and Polish translations from POEditor (#3426)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-12-17 17:48:02 -05:00
qx100
dcc84e29d9 fix(ui): Update Chinese (simplified) Translation (#3490) 2024-12-17 17:45:53 -05:00
Xabi
0d520dea2d fix(ui): update Basque (#3542)
* fix(ui): update eu.json

Added:
- lastAccessAt

Updated:
- lastLoginAt

* fix(ui): update eu.json

Updated:
- logout
2024-12-17 17:44:38 -05:00
Deluan
2bb918f8a1 chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-17 17:41:57 -05:00
Deluan Quintão
8e2052ff95 feat(Insights): add anonymous usage data collection (#3543)
* feat(insights): initial code (WIP)

* feat(insights): add more info

* feat(insights): add fs info

* feat(insights): export insights.Data

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): more config info

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(insights): move data struct to its own package

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(insights): omit some attrs if empty

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): send insights to server, add option to disable

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): remove info about anonymous login

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(insights): fix lint

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): disable collector if EnableExternalServices is false

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): fix type casting for 32bit platforms

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): remove EnableExternalServices from the collection (as it will always be false)

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(insights): fix lint

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(insights): rename function for consistency

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): log the data sent to the collector server

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): add last collection timestamp to the "about" dialog.

Also add opt-out info to the SignUp form

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): only sends the initial data collection after an admin user is created

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): remove dangling comment

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): Translate insights messages

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): reporting empty library

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move URL to consts.js

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-17 17:10:55 -05:00
Deluan
bc3576e092 chore(deps): bump prettier
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-10 11:17:30 -05:00
Deluan
44bc70b269 chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-10 11:14:21 -05:00
Deluan
297f72ff1a chore(deps): bump Alpine version
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-09 16:54:11 -05:00
Deluan
181c29613f chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-09 16:50:52 -05:00
Kendall Garner
1a36f06147 fix(ui): service worker crashing on precacheAndRoute (#3528) 2024-12-08 17:43:34 -05:00
Deluan
9cbdb20a31 fix(server): don't try to save JWT if it fails to encrypt
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-05 22:19:39 -05:00
Deluan
7f030b0859 fix(server): encrypt jwt secret at rest
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-05 21:52:15 -05:00
Deluan
177a1f853f fix(server): more race conditions when updating artist/album from external sources
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-04 17:34:26 -05:00
Deluan
627417dae3 fix(server): add disc number to fake path.
Also revert "feat(server): enable "Report Real Path" by default"

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-02 09:35:39 -05:00
Deluan
2b0bfbd75a fix(server): race condition when updating artist/album from external sources
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-01 20:16:40 -05:00
Deluan
8fb09e71b6 feat(server): get artist images from Last.fm
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-01 17:31:18 -05:00
Deluan
c94def801e feat(server): enable "Report Real Path" by default
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-01 17:02:15 -05:00
Deluan Quintão
cbf5e3d51b fix(ui): PWA not updating properly in new Vite config (#3493)
* fix: pwa not updating. use the custom code we had before

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: docker build

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-30 10:33:16 -05:00
Deluan
1c0ebb9460 chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-25 17:41:23 -05:00
Deluan
94bc1a1d41 chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-25 17:31:53 -05:00
Kursat Aktas
9ae898d071 feat: add Navidrome Guru on Gurubase.io (#3491)
Signed-off-by: Kursat Aktas <kursat.ce@gmail.com>
2024-11-23 17:29:00 -05:00
Deluan
054946dc42 chore: update sanitize with updated diacritics
See https://github.com/navidrome/navidrome/issues/255#issuecomment-2488595427

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-20 11:31:13 -05:00
Deluan
ccce1c0f6d fix: pre-cache square images, or else they are not useful for the Album Grid
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
81edef925c refactor: when resizing, don't buffer the original image "just in case"
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
2d4f483812 refactor: remove unnecessary intermediate buffer for ffmpeg image extraction
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
d229ff39e5 refactor: reduce GC pressure by pre-allocating slices
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
3982ba7258 revert: separation of write and read DBs
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
1bf94531fd refactor: better implementation of newRefreshQueue.
- use pointer references in channel
 - actually exits when context is canceled

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:07:03 -05:00
Deluan
6c38dc234f refactor: change toSQL to use ReplaceAllStringFunc, to cause less static allocations
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-18 14:07:41 +02:00
Deluan
c1adf407a1 refactor: load translations with sync.OnceValues
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-18 14:07:31 +02:00
Deluan
c952dc343a chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-11 13:47:01 -05:00
Deluan
3671598121 chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-11 13:30:09 -05:00
Deluan Quintão
cd0cf7c12b feat: cache login background images (#3462)
* feat: use direct links to unsplash for background images

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: cache images from unsplash

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: use cache.HTTPClient to reduce complexity

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: remove magic numbers

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-08 20:23:30 -05:00
Deluan Quintão
6c6223f2f9 fix: forcing transcoding when client does not specify transcoding options (#3455)
* fix: wip

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: revert #3227

It is not respecting the server configured transcoding for the player

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-05 20:39:05 -05:00
Deluan
6ff7ab52f4 chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-04 12:27:55 -05:00
Deluan
faed2ea8d7 chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-04 12:26:39 -05:00
Deluan
cf69df877a chore(deps): bump js dependencies 2024-10-28 22:07:36 -04:00
Deluan
075a7e2640 chore(deps): bump go dependencies 2024-10-28 22:02:52 -04:00
Deluan Quintão
3fda7445b0 fix(server): try to find proper embedded front cover - only for vorbis comments for now (#3348)
* fix(artwork): get the first image from vorbis comments, not the last. fixes #3254

This uses a fork for now.

* fix(artwork): prioritize getting embedded types that are listed as "front" covers

* fix: cleanup
2024-10-27 21:59:22 -04:00
Deluan Quintão
fcb5e1b806 fix(server): fix case-insensitive sort order and add indexes to improve performance (#3425)
* refactor(server): better sort mappings

* refactor(server): simplify GetIndex

* fix: recreate tables and indexes using proper collation

Also add tests to ensure proper collation

* chore: remove unused method

* fix: sort expressions

* fix: lint errors

* fix: cleanup
2024-10-26 14:06:34 -04:00
Kendall Garner
154e13f7c9 build: add packages for deb and rpm to release (#3202)
* support packing deb/rpm/archlinux

* .-.

* initial test

* fix postinstall, remove execstop

* bash -> sh, create toml manually if it doesn't exist (thanks debian)

* don't forget that newline

* postrm

* comments, contrib -> packaging/linux

* contrib > packaging in .goreleaser

* actually add toml

* openrc/sysv templates

* add apk. nothing else yet

* wait, we have a ntive uninstall

* fix: merge errors, move packaging to release

* chore: remove old goreleaser conf

* ci: remove `release` dependency on `docker push`

* ci: fix release version

* ci: upload packages

* ci: try to fix json file list

* ci: replace the json file list with a txt artifact

* postremove -> preremove, skip install/remove error

* actually do preremove

* better preremove

* ci: fix

* ci: fix?

* ci: clean-up

* ci: try to change labels and filenames

* ci: fix?

* ci: fix?

* ci: add `make package` target

* ci: make labels more readable

hope it doesn't break the pipeline again

* build: remove alpine and archlinux packages, for now.

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-10-26 13:31:45 -04:00
Deluan Quintão
69e2a6d620 build(netgo): make sure the project is always compiled with netgo build tag (#3428)
* build(netgo): make sure the project is always compiled with `netgo` build tag

* docs(netgo): better comments
2024-10-26 13:28:23 -04:00
dependabot[bot]
15b2dc6b48 chore(deps-dev): bump eslint-plugin-jsx-a11y in /ui (#3416)
Bumps [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y) from 6.10.0 to 6.10.1.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/compare/v6.10.0...v6.10.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-jsx-a11y
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:24:43 -04:00
dependabot[bot]
1c48a55759 chore(deps-dev): bump eslint-plugin-react-refresh in /ui (#3419)
Bumps [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) from 0.4.12 to 0.4.13.
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.12...v0.4.13)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:16:45 -04:00
dependabot[bot]
a9b301dfc5 chore(deps-dev): bump @testing-library/jest-dom in /ui (#3418)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.6.1 to 6.6.2.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.6.1...v6.6.2)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:16:21 -04:00
dependabot[bot]
b86a69567d chore(deps-dev): bump @types/node from 22.7.6 to 22.7.7 in /ui (#3417)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.7.6 to 22.7.7.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:13:39 -04:00
dependabot[bot]
67474b776c chore(deps-dev): bump @vitejs/plugin-react from 4.3.2 to 4.3.3 in /ui (#3415)
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.3.2 to 4.3.3.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/v4.3.3/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:13:30 -04:00
Deluan
9d8c49750e ci: ignore refactor commits in release notes 2024-10-23 23:00:32 -04:00
Deluan Quintão
a557f37834 refactor: small improvements and clean up (#3423)
* refactor: replace custom map functions with slice.Map

* refactor: extract StringerValue function

* refactor: removed unnecessary if

* chore: removed invalid comment

* refactor: replace more map functions

* chore: fix FFmpeg typo
2024-10-22 22:54:31 -04:00
Kendall Garner
0a650de357 feat(subsonic): add MusicBrainz ID and Sort Name to getArtists 2024-10-22 22:00:31 -04:00
Rob Emery
9c3b456165 feat(build): MSI installer improvements (#3376)
* feat(build): add a make target to build a msi installer locally

* Testing wrapping the executable in cmd

* build(ci): build msis in parallel

* feat(server): add LogFile config option

* Revert "Testing wrapping the executable in cmd"

This reverts commit be29592254.

* Adding --log-file for service executable

* feat(ini): wip

* feat(ini): parse nested ini section

* fix(conf): fix fatal error messages

* Now navidrome supports INI, we can use the built-in msi ini system
and not require the VBScript to convert it into toml

* File needs to be called .ini to be parsed as an INI and correct filename
needs to be passed to the service

* fix(msi): build msi locally

* fix(msi): pipeline

* fix(msi): pipeline

* fix(msi): pipeline

* fix(msi): pipeline

* fix(msi): pipeline

* fix(msi): Makefile

* fix(msi): more clean up

* fix(log): convert LF to CRLF on Windows

* fix(msi): config filename should be case-insensitive

* fix(msi): make it a little more idiomatic

* Including the latest windows release of ffmpeg into the msi
as built by https://www.gyan.dev/ffmpeg/builds/ (linked
to on the official ffmpeg source)

* This should version independent

* Need bash expansion for the * to work

* This will run twice, once for x86 and once for x64, I'll make it cache
the executable for now as it'll be quicker

* Silencing wget

* Add ffmpeg path to the config so Navidrome knows where to find it

* refactor: download ffmpeg from our repository

* When going back from the "Are you ready to install?" it should go back to the
Settings dialogue that you just came from

* fix: comments

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-10-22 19:32:56 -04:00
Deluan
23bebe4e06 feat(subsonic): getOpenSubsonicExtensions is now public 2024-10-21 17:21:18 -04:00
Deluan
8808eaddda fix(server): allow extra spaces in transcoding commands 2024-10-20 19:35:16 -04:00
Deluan
bbb3182bc9 refactor(server): remove ffmpeg unused code 2024-10-20 19:35:16 -04:00
Caio Cotts
82633d7490 fix(playlists): make the m3u parser case-insensitive again #3410 2024-10-20 14:21:39 -04:00
Deluan
28668782c6 fix(server): FFmpegPath can contain spaces 2024-10-20 13:58:39 -04:00
Deluan
97c06aba1a perf(server): add index for sort tags.
Improves search performance when searching with PreferSortTags=true
2024-10-19 20:46:54 -04:00
Deluan
9c46e2b262 fix: use docker buildx, as required by Linux 2024-10-19 11:55:03 -04:00
Deluan Quintão
3713032f57 fix(ui): update translations from POEditor (#3349)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-10-17 20:29:54 -04:00
Ivan Pešić
a358d107aa fix(ui): update Serbian translation (#3361) 2024-10-17 20:24:50 -04:00
jan666
5f6a90e5aa build: fix build on FreeBSD (#3403)
- vite: use rollup/wasm-node
- use vitejs/plugin-react instead of plugin-react-swc
2024-10-17 19:26:53 -04:00
Deluan Quintão
0232afd98d fix(ui): service worker does not load new version of ui (#3402)
* fix(pwa): wip

* fix(pwa): wip
2024-10-16 21:39:14 -04:00
Deluan
270ae3549d chore(deps): bump peter-evans/create-pull-request from 6 to 7 2024-10-16 20:31:45 -04:00
Deluan Quintão
8b5af67647 feat(subsonic): support OS clearing play queue (#3399) 2024-10-16 10:59:22 -04:00
Deluan Quintão
00c6a0ed1f fix: use target platform to build final image (#3397)
* fix: use target platform to build final image

* fix: remove armv5 from supported images
2024-10-15 22:47:05 -04:00
dependabot[bot]
ff79ac4336 chore(deps-dev): bump eslint-plugin-react from 7.37.0 to 7.37.1 in /ui (#3362)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.37.0 to 7.37.1.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.37.0...v7.37.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:59:19 -04:00
dependabot[bot]
8d37781a47 chore(deps-dev): bump eslint-plugin-react-hooks in /ui (#3381)
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.6.2 to 5.0.0.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/eslint-plugin-react-hooks@5.0.0/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:47:38 -04:00
dependabot[bot]
a6fb7fd705 chore(deps-dev): bump typescript from 5.6.2 to 5.6.3 in /ui (#3380)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.6.2 to 5.6.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.6.2...v5.6.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:45:16 -04:00
dependabot[bot]
fd81039f1b chore(deps-dev): bump @vitest/coverage-v8 from 2.1.1 to 2.1.3 in /ui (#3379)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 2.1.1 to 2.1.3.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v2.1.3/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:32:13 -04:00
dependabot[bot]
bc4aa55de3 chore(deps-dev): bump @types/node from 22.7.4 to 22.7.5 in /ui (#3378)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.7.4 to 22.7.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:31:54 -04:00
dependabot[bot]
6ec6ac1595 chore(deps-dev): bump vite from 5.4.8 to 5.4.9 in /ui (#3382)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.8 to 5.4.9.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.9/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.9/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:31:28 -04:00
Deluan
06e38a8024 chore(deps): go mod tidy 2024-10-15 19:29:13 -04:00
Deluan
6e5eea980d chore(deps): bump Go dependencies 2024-10-15 19:24:22 -04:00
Deluan Quintão
943b456d3f fix: do not try to push to ghcr.io without proper permissions (#3395) 2024-10-15 19:10:44 -04:00
Deluan Quintão
16d1314a68 fix: do not add nil filters (#3394) 2024-10-15 19:03:16 -04:00
Deluan Quintão
ae6499b941 fix: PRs should not try to push to docker (#3393) 2024-10-15 18:28:27 -04:00
Deluan Quintão
214287e00d build: new pipeline, new way to cross-compile and build docker images locally. (#3388)
* build: new pipeline, new way to cross-compile and build docker images locally. (#3383)

* build: use alternative repositories

* build: fix

* build: validate taglib downloads

* build: control concurrency

* build: validate xx version

* build: remove taglib download validation as the version can be changed as an argument.
2024-10-15 16:46:01 -04:00
Deluan
af1add4312 Revert "build: new pipeline, new way to cross-compile and build docker images locally. (#3383)"
This reverts commit b14c790641.
2024-10-14 18:52:02 -04:00
Deluan Quintão
b14c790641 build: new pipeline, new way to cross-compile and build docker images locally. (#3383)
* refactor(ci): faster pipeline, add support for darwin/arm64 (#26)

* feat: WIP

* feat: WIP - all except windows

* fix: Bump crazymax/osxcross to 14.5

* feat: bundle UI

* fix: works on all 10!

* fix: WIP

* fix: add git sha and tag

* fix: download taglib from cross-taglib

* feat: add more dependabot coverage

* feat: build JS bundle using Docker

* refactor: pipeline

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: real

* fix: no container

* fix: no container

* fix: pkg-config

* fix: pkg-config

* fix: pkg-config

* fix: pkg-config

* fix: pkg-config

* fix: add lint

* fix: add lint

* fix: add lint

* fix: add lint

* fix: add lint

* fix: add lint

* fix: add js

* fix: gittags

* fix: gittags

* test: is_release

* test: is_release

* test: is_release

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* fix: extract download taglib action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: add msi

* fix: add msi

* fix: add msi

* fix: add msi

* fix: add msi

* test: full

* test: full

* test: disable some platforms to avoid hitting the rate limit

* test: disable some platforms to avoid hitting the rate limit

* fix: use ecr.aws for base images

* test: full release

* test: full release

* fix: clean-up

* refactor: pipeline clean-up (#32)

* fix: clean-up

* fix: clean-up

* fix: clean-up

* fix: fetch all tags

* fix: version

* fix: version

* fix: no need to setup QEMU

* fix: don't try to push images in unauthorized branches

* fix: check push enabled

* fix: change layout?
2024-10-14 18:41:19 -04:00
Deluan
d9fa19dab3 build(ci): bump goreleaser to 2.3.2 2024-10-06 13:20:31 -04:00
Egor
0281d06b01 feat(ui): Allow drag-and-drop song title from player to sidebar playlist (#2435)
* feat(ui): Allow drag-and-drop song title from player to sidebar playlist

Signed-off-by: egor.aristov <egor.aristov@vk.team>

* prettier

---------

Signed-off-by: egor.aristov <egor.aristov@vk.team>
Co-authored-by: egor.aristov <egor.aristov@vk.team>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-10-02 13:10:24 -04:00
Lokke
de04393b47 fix(ui): update German translation (#3345)
Update German translation with minor adjustments
2024-10-02 08:48:47 -04:00
Kendall Garner
55730514ea feat(server): provide native backup/restore mechanism (#3194)
* [enhancement]: Provide native backup/restore mechanism

- db.go: add Backup/Restore functions that utilize Sqlite's built-in online backup mechanism
- support automatic backup with schedule, limit number of files
- provide commands to manually backup/restore Navidrome

Notes:
`Step(-1)` results in a read-only lock being held for the entire duration of the backup.
This will block out any other write operation (and may hold additional locks.
An alternate implementation that doesn't block but instead retries is available at https://www.sqlite.org/backup.html#:~:text=of%20a%20Running-,database,-%2F*%0A**%20Perform%20an%20online (easily adaptable to go), but has the potential problem of continually getting restarted by background writes.

Additionally, the restore should still only be called when Navidrome is offline, as the restore process does not run migrations that are missing.

* remove empty line

* add more granular backup schedule

* do not remove files when bypass is set

* move prune

* refactor: small nitpicks

* change commands and flags

* tests, return path from backup

* refactor: small nitpicks

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-10-01 19:58:54 -04:00
Rob Emery
768160b05e feat: Windows MSI installer and service support (#3125)
* First version/rough layout of the required wix to build an MSI that embeds everything

* Don't need revision number

* produced exe from existing build process is navidrome not Navidrome

* Adding Kardianos wrapper around Cobra so the callbacks are handled
automatically (this is basically only for windows)

* Adding pointless check to shut up lint for now

* make format

* Revert disabling npm tidy

* Using Kardianos always will result in the application hanging so it
needs only be wrapped to handle the callbacks if it's being used in
the service context, otherwise use cobra directly

* Copying in service installation etc from https://github.com/navidrome/navidrome/pull/2295

* Under Linux this installs a user service (I don't think this is
correct, but lets get this working first). User units/services
cannot depends on system units, so previously this bombed out
with Exit Code 5.

* Under Windows we can install both the x86 and x64 builds, they
will install to different folders, but previously they would
overwrite the service as they were both called Navidrome. Now,
it will install 2 services. This will still be weird/broken as
they will attempt to listen on the same port, however uninstalling
the "wrong" arch will not cause the "right" one to be partially
uninstalled anymore

* Reverting changes to the context as they don't really seem necessary anyway

* Need to consistently name the service

* Fixing broken context

* The included files should be removed when the app is uninstalled

* Reverting back to the original context here, I don't think
it makes any difference to running under kardianos

* Let's see what we have immediately available

* OK, the build takes ages so let's just try and do the whole thing in one go, maybe we'll get lucky

* Need -r on directory copy, plus we'll probably need to install wixl

* No sudo cmd, so I assume this runs as root

* WORKSPACE!

* Moving the version to be a single variable, we'll probably be able to pull it from the github tag or whatever

* Might as well put the msi in the right folder, it's tidier

* Writing the version number into the msi, from the output of goreleaser

* Using jq to parse the goreleaser metadata, so need to install it

* MSI only supports numerical version numbers, so I'll make the "snapshot" version .1 minor patch greater

* -r or --raw (on newer versions) means we don't get the "" around the value

* Running as a user service I think makes limited sense for this

* Will now ask for configuration settings during install.

MSI/WiX only supports writing out INI files, Toml is almost
INI compatable, except that the INI needs to write out a section
first, so we need to have a script to strip that off.

We are forced to display a License.rtf file by the UI so I think
the build process should probably rename the default licence file
and that will suffice.

Uninstalling works cleanly, howvever upgrades seem to leave the old
version installed in "programs and features" currently.

Adding the UI has introduced a requirement for WiX 0.103

* Updating the build to include --ext ui for the new config ui

* Configuration dialog should not display for upgrades as the config file
is already written

* Making description consistent with the systemd service and making
the build process produce the required License.rtf

* Fixing " non-constant format string in call to fmt.Errorf (govet)"

* Its a string, not an int; read better.

* Wixl 103 is required for --ext ui, so we need 24.04

* OK this is still installing Wix 0.101, maybe it all needs to be 24.04?

* Switching the builds back to ubuntu-latest (22.04 at current)
as it runs on a custom container, it's actually debian anyway
Moving msi build into its own job so it can run on 24.04 so
we have access to wixl 0.103 for --ext ui support

* Forcing build

* Whitespace fix

* Adding sudo I guess

* Gotta checkout as well

* Adding debugging for when there's soemthing wrong with the paths

* Adding more ls to see if the output has worked

* The msi's are in subdirs

* Actually they're in the ./wix directory

* Still can't find these msi's?

* I think that was being treated literally previously

* No idea why this isn't working, give it a relative path instead?

* Making explicit on the dialogue that the configuration file will be
where the installation dir is

* The lint keeps failing and it's just getting in the way so I'll
turn this off for now and we'll edit out this commit from the merge

* Cutting more out of the build to get more stuff out of the way

* Need to increase the width to fit the text in

* Calling everything License.rtf, presumably one of them is correct

* I am pretty sure the License.rtf loading is broken under Wixl; so
let's just bypass the EULA from the UI which is a nicer experience
for the users anyway

* This needs to be after WelcomeDlg now the Eula isn't displayed

* You're supposed to be able to use <WixVariable> to override the
location that the bmp's are loaded from, I can't get this to work
under wixl so I'm guessing given that the ui extension is new, it
hasn't been implemented with that in mind. So we'll hack it by
overwriting the files installed with the package.

* We should make this less brittle so when wixl is updated it still works

* Re-enabling the lint and tests etc

* Improving the scaling quality and removing borders from images to
tidy them up a tad

* Pretty sure this isn't necessary as MY_PROPERTY will always be false

* Without publishing this event, we can't continue to the next dialogue
however I think we should be able to get away without the property

* Refactoring out the duplication so we only have one service
defined and we can run that either way

* Pushing the Interactive check into the root commmand? Feels like it
is probably getting closer to the right place at least

* go tidy

* OK this didn't work under windows, I'm guessing it's because
it's lacking all the metadata about the service it needs to
report back to Windows on.

* We need to run service execute now so that the windows
service will behave (hopefully)!

* Lint

* go tidy

* Renaming service to "navidrome" rather than "Navidrome" as this
is the filename that systemd writes and it's unusual to have
capital letters in service names under Linux.
Switching to use service execute for Linux to mirror Windows

* Need to provide the arguments to append

* Without passing the context around, the DB isn't closed gracefully
so we end up with with .db-shm and .db-wal files for recovery

* We should log fatal rather than outputting directly to stdout

* go tidy

* refactor: small nitpicks

* fix: terminate service gracefully

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-10-01 19:40:53 -04:00
Deluan
b7285b28cf fix(test): vitest was hanging due to vite-plugin-eslint plugin 2024-10-01 17:12:54 -04:00
Deluan
eab6aadc0f chore(deps): bump Go to 1.23.2 2024-10-01 14:54:22 -04:00
ChekeredList71
640a734896 fix(ui): update Hungarian translation (#3346)
Added new Hungarian translation for "lastAccessAt".
2024-10-01 14:13:15 -04:00
Deluan Quintão
a9334b7787 feat(ui): show user's lastAccess (#3342)
* feat(server): update user's lastAccess

* feat(ui): show user's lastAccess
2024-09-30 20:46:10 -04:00
dependabot[bot]
5e8085bf3c chore(deps-dev): bump eslint-plugin-react from 7.36.1 to 7.37.0 in /ui (#3334)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.36.1 to 7.37.0.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.36.1...v7.37.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 14:31:58 -04:00
dependabot[bot]
b2eb533082 chore(deps-dev): bump @vitejs/plugin-react-swc in /ui (#3339)
Bumps [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) from 3.7.0 to 3.7.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react-swc/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react-swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react-swc/compare/v3.7.0...v3.7.1)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react-swc"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 14:31:41 -04:00
dependabot[bot]
936af2d895 chore(deps-dev): bump @types/node from 22.6.1 to 22.7.4 in /ui (#3335)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.6.1 to 22.7.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 14:31:25 -04:00
dependabot[bot]
b1c18a428b chore(deps-dev): bump vite from 5.4.7 to 5.4.8 in /ui (#3340)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.7 to 5.4.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.8/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.8/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 14:31:10 -04:00
Deluan Quintão
1fac9cc3ee chore: add poeditor logo to readme (#3329) 2024-09-30 13:25:03 -04:00
Deluan
92a1f19271 fix(scanner): make activity panel update rate configurable 2024-09-30 12:06:23 -04:00
crazygolem
06c9c1e64a feat(server): require explicitly enabling reverse proxy auth for unix sockets (#3062) 2024-09-29 13:28:44 -04:00
Deluan
ed3ab5385d chore(deps): bump rollup from 2.79.1 to 2.79.2 in /ui 2024-09-28 11:56:35 -04:00
Deluan Quintão
fcdd30ba8f build(ui): migrate from CRA/Jest to Vite/Vitest (#3311)
* feat: create vite project

* feat: it's alive!

* feat: `make dev` working!

* feat: replace custom serviceWorker with vite plugin

* test: replace Jest with Vitest

* fix: run prettier

* fix: skip eslint for now.

* chore: remove ui.old folder

* refactor: replace lodash.pick with simple destructuring

* fix: eslint errors (wip)

* fix: eslint errors (wip)

* fix: display-name eslint errors (wip)

* fix: no-console eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: build

* fix: pwa manifest

* refactor: pwa manifest

* refactor: simplify PORT configuration

* refactor: rename simple JS files

* test: cover playlistUtils

* fix: react-image-lightbox

* feat(ui): add sourcemaps to help debug issues
2024-09-28 11:54:36 -04:00
dependabot[bot]
dd48a23f92 chore(deps): bump github.com/unrolled/secure from 1.15.0 to 1.16.0 (#3327)
Bumps [github.com/unrolled/secure](https://github.com/unrolled/secure) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/unrolled/secure/releases)
- [Commits](https://github.com/unrolled/secure/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: github.com/unrolled/secure
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-28 11:32:21 -04:00
dependabot[bot]
6040a50297 chore(deps): bump alpine from 3.18 to 3.20 in /.github/workflows (#3326)
Bumps alpine from 3.18 to 3.20.

---
updated-dependencies:
- dependency-name: alpine
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-28 11:30:36 -04:00
Deluan
9e5849e4dc build(dependabot): add docker configuration 2024-09-28 10:52:23 -04:00
Kendall Garner
13af8ed43a fix(server): preserve m3u file order on import (#3314)
* fix(playlist): preserve m3u file order on import - 3307

Signed-off-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>

* test(server): cover playlist order

* refactor(server): micro-optimizations

* refactor(server): micro-optimizations

* fix(server): playlists imported from reader (POST /playlist) are not synced

* refactor(server): only allocate the capacity required to hold a playlist chunk

---------

Signed-off-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-27 16:05:12 -04:00
Deluan
825cbcbf53 fix(readme): reddit badge is working again. 2024-09-27 15:52:27 -04:00
Deluan
5be73d404f fix(server): allow changing local login background url 2024-09-27 15:18:20 -04:00
Andy
1fa245d141 fix(ui) update Swedish translation (#3316) 2024-09-27 14:53:11 -04:00
Kendall Garner
782cd26b3d fix(ui): save play mode for player (#3315)
* fix(ui): save play mode for player - 3019

* redux

* redux
2024-09-27 13:13:22 -04:00
Deluan
10a1b5faf8 test(scanner): remove redundant fixture file 2024-09-27 09:53:08 -04:00
dependabot[bot]
84dc10529d chore(deps): bump github.com/prometheus/client_golang from 1.20.3 to 1.20.4 (#3301)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.3 to 1.20.4.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.3...v1.20.4)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-26 18:52:34 -04:00
dependabot[bot]
24d911744e build(deps): bump github.com/pressly/goose/v3 from 3.22.0 to 3.22.1 (#3302)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.22.0 to 3.22.1.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.22.0...v3.22.1)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-26 18:45:04 -04:00
dependabot[bot]
6031d97c9d chore(deps): bump rollup from 2.79.1 to 2.79.2 in /ui (#3319)
Bumps [rollup](https://github.com/rollup/rollup) from 2.79.1 to 2.79.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.79.1...v2.79.2)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-26 18:44:23 -04:00
Deluan
80acfc103f fix(server): throttle events sent to UI when scanning. Relates to #1511
See also: https://github.com/navidrome/navidrome/issues/1186#issuecomment-1554818537
2024-09-26 18:19:20 -04:00
Deluan Quintão
76614b8f16 fix(scanner): update lib.LastScanAt on each rescan (#3313) 2024-09-26 06:16:27 -04:00
Deluan
d31952f469 fix(ui): avoid invalid requests after logoff 2024-09-25 15:14:47 -04:00
Xabi
32d2d7c15b fix(ui): update Basque translation (#3306)
Small, unimportant changes
2024-09-22 12:26:09 -04:00
Deluan Quintão
669c8f4c49 refactor(server): replace RangeByChunks with Go 1.23 iterators (#3292)
* refactor(server): replace RangeByChunks with Go 1.23 iterators

* chore: fix comments re: SQLITE_MAX_VARIABLE_NUMBER

* test: improve playqueue test

* refactor(server): don't create a new iterator when it is not required
2024-09-22 11:47:10 -04:00
Deluan Quintão
3910e77a7a build(ci): change GitHub release notes (#3300) 2024-09-21 17:00:13 -04:00
Kendall Garner
196557a41a fix(ui): show effective dB of track when playing (#3293)
* show effective db of track when playing

* tests
2024-09-21 16:46:14 -04:00
Caio Cotts
11d96f1da4 fix(ui): sort mappings (#3296)
* fix(ui): update sort mapping for title in mediafile repository

* fix(ui): create sort mapping for username in share repository

* fix(ui): create sort mapping for owner_name in playlist repository

* fix(ui): create sort mapping for username in player repository

* fix(ui): remove sort mapping for track number in mediafile repository

* chore: add todo to change user_name
2024-09-20 21:36:59 -04:00
Deluan
e628aafa4b build(go): set toolchain to latest version 2024-09-20 18:04:36 -04:00
Deluan
ecf934feab fix(subsonic): random albums not reshuffling.
See: https://github.com/navidrome/navidrome/issues/3277#issuecomment-2364269787
2024-09-20 16:59:46 -04:00
Deluan
5b89bf747f fix(server): play queue should not return empty entries for deleted tracks 2024-09-20 11:22:37 -04:00
Ivan Pešić
7a6845fa5a feat(ui): add Serbian translation (#3287) 2024-09-20 08:51:40 -04:00
Deluan
b6433057e9 fix(ui): make random albums order stick when coming back to the grid 2024-09-19 20:16:50 -04:00
Deluan
d0784b6a21 chore(ci): change "update translations" PR title 2024-09-19 17:28:01 -04:00
gruneforth
b0e7941abe fix(ui): fix Nuclear Theme (#3291)
* Add Nuclear Theme

* Fix login screen color & Softened "link" coloring

---------

Co-authored-by: grune <grune@grunk.me>
2024-09-19 17:13:44 -04:00
Deluan Quintão
a02cfbe2a7 fix(ui): update German translation (#3290)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-09-19 14:08:44 -04:00
naiar
04603a1ea2 fix(subsonic): honour PreferSortTag when building indexes for getArtist and getIndexes (#3286)
* fix(scanner): use sort_artist_name when the config PreferSortTags is true - #3285

* revert unwanted modifications

* refactor(server): use cmp.Or to simplify nested ifs

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-19 13:44:29 -04:00
Deluan
50870d3e61 fix(ui): sort by favourited 2024-09-19 13:05:26 -04:00
DDinghoya
27780683aa feat(ui): update Korean translation (#3288) 2024-09-19 12:13:50 -04:00
Deluan
5baf0b80aa fix(ui): sort playlist by song duration (#3284) 2024-09-19 08:45:49 -04:00
Deluan
46be041e7b fix(scanner): improve M3U playlist import times (#2706) 2024-09-18 20:12:12 -04:00
Kendall Garner
ee2e04b832 fix(ui): random seed for album list on page reload (#3279)
* random seed for album list on page reload

* Nit: inline variable

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-18 12:35:13 -04:00
Kendall Garner
1ba390a72a random -> SEEDRAND (#3274) 2024-09-17 17:03:12 -04:00
Deluan Quintão
8134edb5d1 Fix genre and id filters (#3273) 2024-09-17 16:59:55 -04:00
dependabot[bot]
910a46120b Bump dompurify from 2.4.5 to 2.5.6 in /ui (#3270)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 2.4.5 to 2.5.6.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/2.4.5...2.5.6)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 20:04:37 -04:00
dependabot[bot]
8c86d0945c Bump github.com/mileusna/useragent from 1.3.4 to 1.3.5 (#3269)
Bumps [github.com/mileusna/useragent](https://github.com/mileusna/useragent) from 1.3.4 to 1.3.5.
- [Release notes](https://github.com/mileusna/useragent/releases)
- [Commits](https://github.com/mileusna/useragent/compare/v1.3.4...v1.3.5)

---
updated-dependencies:
- dependency-name: github.com/mileusna/useragent
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 19:13:29 -04:00
Caio Cotts
42047fde1a Remove shareURL value from config.js 2024-09-15 17:26:58 -04:00
Caio Cotts
2887cd65fc Fix wrong placement of When in test 2024-09-15 17:26:58 -04:00
Caio Cotts
8ac133027d Make the UI use the new ShareURL option 2024-09-15 17:26:58 -04:00
Caio Cotts
f0240280eb Add ShareURL configuration option 2024-09-15 17:26:58 -04:00
Reilly MacKenzie-Cree
d683688b0e Recursively refresh playlist tracks within smart playlist rules (#3018)
* Recursively refresh playlists within smart playlist rules

Signed-off-by: reillymc <reilly@mackenzie-cree.net>

* Clean up recursive smart playlist functions

Signed-off-by: reillymc <reilly@mackenzie-cree.net>

* Add smart playlist refresh timeout config and tests for nested track refetching

Signed-off-by: reillymc <reilly@mackenzie-cree.net>

* Change SmartPlaylistRefreshTimeout to SmartPlaylistRefreshDelay, increase default value

* Revert `smartPlaylistRefreshDelay` default to 5 seconds

---------

Signed-off-by: reillymc <reilly@mackenzie-cree.net>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-15 13:27:54 -04:00
ChekeredList71
180035c1e3 Hungarian patch and typo fix for English (#3263)
* English typo fix

* hungarian-patch

You can find the changes here in detail: https://pastebin.com/GLtmwELv
2024-09-15 11:00:25 -04:00
Deluan
a132755d67 Move update-translations.sh script to workflow directory 2024-09-14 21:37:25 -04:00
Deluan
3107170afd Improve SQL sanitization 2024-09-14 18:53:34 -04:00
dependabot[bot]
d3bb4bb9a1 Bump send and express in /ui (#3260)
Bumps [send](https://github.com/pillarjs/send) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `send` from 0.18.0 to 0.19.0
- [Release notes](https://github.com/pillarjs/send/releases)
- [Changelog](https://github.com/pillarjs/send/blob/master/HISTORY.md)
- [Commits](https://github.com/pillarjs/send/compare/0.18.0...0.19.0)

Updates `express` from 4.20.0 to 4.21.0
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.20.0...4.21.0)

---
updated-dependencies:
- dependency-name: send
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-14 12:36:32 -04:00
dependabot[bot]
41f380451c Bump path-to-regexp and express in /ui (#3255)
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `path-to-regexp` from 1.8.0 to 1.9.0
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v1.8.0...v1.9.0)

Updates `express` from 4.18.1 to 4.20.0
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.1...4.20.0)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-14 12:30:29 -04:00
Deluan
e65eb225c8 Small refactoring
- Remove duplication
- Remove warning about builtin keyword `new`
2024-09-13 20:18:00 -04:00
Deluan
e8d0f2ec2c Allow searching songs by filepath, for songs without Title 2024-09-13 18:04:21 -04:00
Deluan
47872c9e8a Fix pipeline 2024-09-13 17:43:50 -04:00
Deluan
9ae2ec1a07 Ignore #snapshot folders when scanning. Fixes #3257 2024-09-13 17:30:08 -04:00
Deluan
a1866c7ff3 Fix log message 2024-09-13 09:13:51 -04:00
Kendall Garner
9f1794b97e Only refresh smart playlist when fetching first track (#3244)
* Only refresh smart playlist when fetching first track

* res -> w
2024-09-10 20:18:37 -04:00
dependabot[bot]
e1762882e3 Bump github.com/prometheus/client_golang from 1.20.2 to 1.20.3 (#3245)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.2 to 1.20.3.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.20.3/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.2...v1.20.3)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 18:03:16 -04:00
dependabot[bot]
870b217eb9 Bump github.com/pressly/goose/v3 from 3.21.1 to 3.22.0 (#3247)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.21.1 to 3.22.0.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.21.1...v3.22.0)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 17:59:30 -04:00
dependabot[bot]
53af567b45 Bump golang.org/x/image from 0.19.0 to 0.20.0 (#3248)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.19.0 to 0.20.0.
- [Commits](https://github.com/golang/image/compare/v0.19.0...v0.20.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 17:59:01 -04:00
dependabot[bot]
605aaf87d8 Bump github.com/mattn/go-sqlite3 from 1.14.22 to 1.14.23 (#3249)
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.22 to 1.14.23.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.22...v1.14.23)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 17:58:50 -04:00
dependabot[bot]
9950538089 Bump github.com/mattn/go-zglob from 0.0.5 to 0.0.6 (#3231)
Bumps [github.com/mattn/go-zglob](https://github.com/mattn/go-zglob) from 0.0.5 to 0.0.6.
- [Commits](https://github.com/mattn/go-zglob/compare/v0.0.5...v0.0.6)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-zglob
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-02 13:22:49 -04:00
Vlad Shulcz
4a55a148cf refactor(core): Refactor selectTranscodingOptions function (#3227)
* refactor(core): Refactor selectTranscodingOptions function - #3226

Signed-off-by: shulcz <vshulcz@gmail.com>

* chore: Fix selectTranscodingOptions function - #3226

Signed-off-by: shulcz <vshulcz@gmail.com>

* Small refactoring to make code more concise

* Fix log message

---------

Signed-off-by: shulcz <vshulcz@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-02 12:20:23 -04:00
Deluan
c1b75bca51 Improve change detection for POEditor files 2024-09-02 11:02:24 -04:00
Reilly MacKenzie-Cree
5baab4af77 Update dev container to use Go 1.23 and customizations object (#3228)
Signed-off-by: reillymc <reilly@mackenzie-cree.net>
2024-09-01 22:22:32 -04:00
Xabi
4c87a39242 Add Basque localisation (#3221)
* Add Basque localisation

Initial Basque localisation

* Update eu.json

fixes extra dash

* Update eu.json

fixes

* Update eu.json

653098th time's the charm
2024-09-01 16:03:15 -04:00
Deluan
fc5d18feb7 Change error code type to avoid integer overflow conversion warning 2024-09-01 14:49:48 -04:00
Deluan
4612b0a518 Bump Go dependencies 2024-08-31 19:20:38 -04:00
Deluan Quintão
68ddbf4856 Add i18n lint job 2024-08-31 14:54:04 -04:00
dependabot[bot]
a6d72d8623 Bump webpack from 5.76.1 to 5.94.0 in /ui (#3218)
Bumps [webpack](https://github.com/webpack/webpack) from 5.76.1 to 5.94.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.76.1...v5.94.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-29 16:11:25 -04:00
Deluan
1a41525a7e Upgrade go.mod to 1.23, allow override CI_RELEASER_VERSION for make single and make all 2024-08-29 15:14:20 -04:00
Deluan
8ca1aefad6 Change DefaultPlaylistPublicVisibility to false 2024-08-28 19:23:19 -04:00
John White
67d11dd144 feat: imported playlists are public by default (#3143)
* feat: imported playlists are public by default

* chore: make linter happy

---------

Co-authored-by: John White <john@activecode.dev>
2024-08-28 19:20:05 -04:00
Deluan Quintão
9f65f8f5a8 Update translations (#3164)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-08-28 19:14:27 -04:00
Deluan Quintão
bc06a59919 Upgrade TagLib 2.0.2, GoReleaser 2.2.0 (#3217)
* Upgrade ci-goreleaser

* Fix tests

* Fix taglib lib path in macOS
2024-08-28 19:13:08 -04:00
Sunny
6709ab3c5e fix(common): Hide Share/Get Info items in disc context menu - #3204 (#3209)
Signed-off-by: Sunny <sunny@sny.sh>
2024-08-26 21:40:05 -04:00
dependabot[bot]
195f2b3f38 Bump @testing-library/jest-dom from 6.4.8 to 6.5.0 in /ui (#3216)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.4.8 to 6.5.0.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.4.8...v6.5.0)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:30:56 -04:00
dependabot[bot]
6ea688e720 Bump github.com/prometheus/client_golang from 1.20.0 to 1.20.2 (#3213)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.0 to 1.20.2.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.0...v1.20.2)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:30:44 -04:00
dependabot[bot]
496c95fd47 Bump github.com/go-chi/httprate from 0.12.1 to 0.14.0 (#3211)
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.12.1 to 0.14.0.
- [Release notes](https://github.com/go-chi/httprate/releases)
- [Commits](https://github.com/go-chi/httprate/compare/v0.12.1...v0.14.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/httprate
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:30:31 -04:00
dependabot[bot]
108bf31148 Bump github.com/pelletier/go-toml/v2 from 2.2.2 to 2.2.3 (#3212)
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.2.2 to 2.2.3.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Changelog](https://github.com/pelletier/go-toml/blob/v2/.goreleaser.yaml)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.2.2...v2.2.3)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:30:14 -04:00
dependabot[bot]
7c81143ca9 Bump github.com/onsi/ginkgo/v2 from 2.20.0 to 2.20.1 (#3215)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.20.0 to 2.20.1.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.20.0...v2.20.1)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:29:39 -04:00
dependabot[bot]
533c394f09 Bump github.com/jellydator/ttlcache/v3 from 3.2.0 to 3.2.1 (#3214)
Bumps [github.com/jellydator/ttlcache/v3](https://github.com/jellydator/ttlcache) from 3.2.0 to 3.2.1.
- [Release notes](https://github.com/jellydator/ttlcache/releases)
- [Commits](https://github.com/jellydator/ttlcache/compare/v3.2.0...v3.2.1)

---
updated-dependencies:
- dependency-name: github.com/jellydator/ttlcache/v3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:29:28 -04:00
Deluan
c95fa11a2f Remove potential integer overflow conversion uint64 -> int64 2024-08-22 19:28:22 -04:00
Deluan
5d81849603 Fix lint errors 2024-08-21 12:15:25 -04:00
dependabot[bot]
1a8bef0743 Bump react-icons from 5.2.1 to 5.3.0 in /ui (#3200)
Bumps [react-icons](https://github.com/react-icons/react-icons) from 5.2.1 to 5.3.0.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.2.1...v5.3.0)

---
updated-dependencies:
- dependency-name: react-icons
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 18:21:10 -04:00
dependabot[bot]
85bf7b5684 Bump @testing-library/jest-dom from 6.4.6 to 6.4.8 in /ui (#3172)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.4.6 to 6.4.8.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.4.6...v6.4.8)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 18:13:38 -04:00
dependabot[bot]
bdbff1ea38 Bump prettier from 3.3.2 to 3.3.3 in /ui (#3171)
Bumps [prettier](https://github.com/prettier/prettier) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 18:12:45 -04:00
dependabot[bot]
5d58048780 Bump github.com/prometheus/client_golang from 1.19.1 to 1.20.0 (#3199)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.1 to 1.20.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.19.1...v1.20.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 18:12:29 -04:00
Rob Emery
723f01d98c Fixing Build/lint error: "non-constant format string in call to fmt.Errorf (govet)" (#3198)
* Fixing " non-constant format string in call to fmt.Errorf (govet)"

* Its a string, not an int; read better.
2024-08-19 17:58:35 -04:00
Deluan Quintão
c4bd0e67fa Upgrade Go to 1.23 (#3190)
* Upgrade to Golang 1.23rc1

* Fix imports

* Go 1.23 final version

* Fix lint compatibility with ci-goreleaser
2024-08-19 17:47:54 -04:00
Deluan
0c33523f45 Bump dependencies 2024-08-10 12:22:36 -04:00
Deluan
14d085f651 Deprecate buildall 2024-08-07 16:19:44 -04:00
Deluan
4d4c71212f Build UI bundle on demand 2024-08-07 15:36:29 -04:00
Deluan
e1ba152a38 Reduce noise in logs when pre-caching artwork 2024-08-07 13:08:54 -04:00
Deluan
eaa7f7c7e9 Fix Player filter 2024-08-05 18:21:21 -04:00
Kendall Garner
290333ec59 Use same key for replaygain's preAmp (#3184)
Resolves #2933. To prevent this from happening again, make the localstorage keys consts for set/get
2024-08-03 21:18:41 -04:00
Kendall Garner
fa85e2a781 Use userId in player, other fixes (#3182)
* [bugfix] player: use userId, other fixes

This PR primarily resolves #1928 by switching the foreign key of `player` from `user.user_name` to `user.id`.
There are also a few other fixes/changes:

- For some bizarre reason, `ip_address` is never returned from `read`/`get`. Change the field to `ip`, which works. Somehow
- Update `players_test.go` mock to also check for user agent, replicating the actual code
- Update `player_repository.go` `isPermitted` to check user id. I don't know how this worked before...
- tests!
- a few places referred to `typ`, when it is really `userAgent`. Change the field names

* baseRequest -> selectPlayer

* remove comment

* update migration, make all of persistence foreign key enabled

* maybe don't forget to save the file first
2024-08-03 13:37:21 -04:00
dependabot[bot]
5360283bb0 Bump github.com/onsi/gomega from 1.33.1 to 1.34.0 (#3176)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.33.1 to 1.34.0.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.33.1...v1.34.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 21:35:00 -04:00
dependabot[bot]
e59d81bf78 Bump github.com/microcosm-cc/bluemonday from 1.0.26 to 1.0.27 (#3141)
Bumps [github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday) from 1.0.26 to 1.0.27.
- [Release notes](https://github.com/microcosm-cc/bluemonday/releases)
- [Commits](https://github.com/microcosm-cc/bluemonday/compare/v1.0.26...v1.0.27)

---
updated-dependencies:
- dependency-name: github.com/microcosm-cc/bluemonday
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-28 15:33:40 -04:00
Deluan
7b2ddfd65a Fix "Cannot read properties of undefined". Closes #3070 2024-07-25 17:22:04 -04:00
Deluan
76c3f5131a Use SHA256 in Gravatar URLs 2024-07-23 17:49:46 -04:00
Soderes
f577704d7a Add Hungarian language (#3157) 2024-07-22 18:10:41 -04:00
dependabot[bot]
f46ff73c53 Bump github.com/go-chi/httprate from 0.9.0 to 0.10.0 (#3160)
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.9.0 to 0.10.0.
- [Commits](https://github.com/go-chi/httprate/compare/v0.9.0...v0.10.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/httprate
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 15:31:46 -04:00
Deluan
d046c180bf Fix race condition 2024-07-22 14:27:02 -04:00
Caio Cotts
9b4abd9e5a Add Auto-Import toggle switch to playlists list view. 2024-07-18 00:07:59 +02:00
Caio Cotts
0de5f594fe Remove unnecessary Fragment component. 2024-07-18 00:07:59 +02:00
Deluan
33717f26d4 Fix album sorting in Artist page 2024-07-04 17:21:31 -04:00
dependabot[bot]
6722395879 Bump github.com/unrolled/secure from 1.14.0 to 1.15.0 (#3127)
Bumps [github.com/unrolled/secure](https://github.com/unrolled/secure) from 1.14.0 to 1.15.0.
- [Release notes](https://github.com/unrolled/secure/releases)
- [Commits](https://github.com/unrolled/secure/compare/v1.14.0...v1.15.0)

---
updated-dependencies:
- dependency-name: github.com/unrolled/secure
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 15:53:01 -04:00
dependabot[bot]
2667ad3921 Bump github.com/go-chi/chi/v5 from 5.0.14 to 5.1.0 (#3126)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.14 to 5.1.0.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.0.14...v5.1.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 15:52:29 -04:00
Kendall Garner
3e1fa20413 fix background color for nord theme (#3124) 2024-06-29 18:50:33 -04:00
gruneforth
1802015737 Add Nuclear Theme (#3098) 2024-06-29 17:04:30 -04:00
Deluan
47378c6882 Remove unnecessary annotation table primary key 2024-06-29 11:45:41 -04:00
dependabot[bot]
81459cc421 Bump github.com/spf13/cobra from 1.8.0 to 1.8.1 (#3095)
Bumps [github.com/spf13/cobra](https://github.com/spf13/cobra) from 1.8.0 to 1.8.1.
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.8.0...v1.8.1)

---
updated-dependencies:
- dependency-name: github.com/spf13/cobra
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:47:24 -04:00
dependabot[bot]
4cda3a58dc Bump braces from 3.0.2 to 3.0.3 in /ui (#3085)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:47:00 -04:00
dependabot[bot]
56557bb0f3 Bump @testing-library/jest-dom from 6.4.5 to 6.4.6 in /ui (#3096)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.4.5 to 6.4.6.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.4.5...v6.4.6)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:46:44 -04:00
dependabot[bot]
c60f443179 Bump prettier from 3.3.1 to 3.3.2 in /ui (#3097)
Bumps [prettier](https://github.com/prettier/prettier) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.1...3.3.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:46:34 -04:00
dependabot[bot]
fa3998d6e1 Bump github.com/pressly/goose/v3 from 3.20.0 to 3.21.1 (#3114)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.20.0 to 3.21.1.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.20.0...v3.21.1)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:45:44 -04:00
dependabot[bot]
8542ac96c0 Bump github.com/go-chi/chi/v5 from 5.0.12 to 5.0.14 (#3115)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.12 to 5.0.14.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.0.12...v5.0.14)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:45:25 -04:00
dependabot[bot]
4557add7ef Bump github.com/lestrrat-go/jwx/v2 from 2.0.21 to 2.1.0 (#3113)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.21 to 2.1.0.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.21...v2.1.0)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:45:13 -04:00
dependabot[bot]
004fae43f5 Bump golang.org/x/image from 0.17.0 to 0.18.0 (#3119)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.17.0 to 0.18.0.
- [Commits](https://github.com/golang/image/compare/v0.17.0...v0.18.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:44:58 -04:00
Deluan
7111535963 Don't panic on PostScan errors. Fix #3118 2024-06-25 17:14:17 -04:00
Deluan
3bc9e75b28 Evict expired items from SimpleCache 2024-06-24 17:32:34 -04:00
Deluan
3993c4d17f Upgrade to ttlcache/v3 2024-06-21 18:09:34 -04:00
Deluan
29b7b740ce Also use SimpleCache in cache.HTTPClient 2024-06-21 17:40:18 -04:00
Deluan
29bc17acd7 Wrap ttlcache in our own SimpleCache implementation 2024-06-21 17:21:09 -04:00
Deluan
4044642abf Add http headers to trace log 2024-06-16 22:31:47 -04:00
Kendall Garner
88eac6d7f3 fix album/media file random sort (#3089) 2024-06-12 21:06:59 -04:00
dependabot[bot]
f267f55713 Bump github.com/prometheus/client_golang from 1.19.0 to 1.19.1
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.0 to 1.19.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.19.0...v1.19.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:56:03 -04:00
dependabot[bot]
58990c4830 Bump @testing-library/jest-dom from 6.4.2 to 6.4.5 in /ui
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.4.2 to 6.4.5.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.4.2...v6.4.5)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:50:38 -04:00
dependabot[bot]
7a20233a35 Bump ejs from 3.1.9 to 3.1.10 in /ui
Bumps [ejs](https://github.com/mde/ejs) from 3.1.9 to 3.1.10.
- [Release notes](https://github.com/mde/ejs/releases)
- [Commits](https://github.com/mde/ejs/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: ejs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:48:44 -04:00
dependabot[bot]
45679e11c2 Bump clsx from 2.1.0 to 2.1.1 in /ui
Bumps [clsx](https://github.com/lukeed/clsx) from 2.1.0 to 2.1.1.
- [Release notes](https://github.com/lukeed/clsx/releases)
- [Commits](https://github.com/lukeed/clsx/compare/v2.1.0...v2.1.1)

---
updated-dependencies:
- dependency-name: clsx
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:42:52 -04:00
dependabot[bot]
05f34b0cce Bump golang.org/x/image from 0.16.0 to 0.17.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/image/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:35:02 -04:00
dependabot[bot]
586e725d6c Bump react-icons from 5.1.0 to 5.2.1 in /ui
Bumps [react-icons](https://github.com/react-icons/react-icons) from 5.1.0 to 5.2.1.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.1.0...v5.2.1)

---
updated-dependencies:
- dependency-name: react-icons
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:27:47 -04:00
dependabot[bot]
a7c4c72dc6 Bump uuid from 9.0.1 to 10.0.0 in /ui
Bumps [uuid](https://github.com/uuidjs/uuid) from 9.0.1 to 10.0.0.
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v9.0.1...v10.0.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:17:49 -04:00
Deluan
232c45bd06 Increase artist image url sizes.
See https://support.symfonium.app/t/artist-picture-less-compressed/4447
2024-06-10 16:33:41 -04:00
Caio Cotts
1b77830eb4 Do not use lastFM api key and secret to determine if LastFM.Enabled should be set. 2024-06-10 16:26:39 -04:00
dependabot[bot]
e535f7eb78 Bump prettier from 3.3.0 to 3.3.1 in /ui
Bumps [prettier](https://github.com/prettier/prettier) from 3.3.0 to 3.3.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.0...3.3.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 15:31:43 -04:00
Deluan
d8b2f3d2cf Don't expose fullText data in the Native API 2024-06-09 11:19:22 -04:00
kartikynwa
56303cde23 Add R128_{TRACK,ALBUM}_GAIN support to the scanner (#3072)
* Add R128 gain tags support to the scanner

* Add R128 test to metadata_internal_test.go

* Pass explicit tag names to getGainValue function
2024-06-08 13:45:06 -04:00
Deluan
e434ca9372 Change resized image cache key 2024-06-08 13:37:30 -04:00
Deluan
3252fab171 Increase artist image url sizes.
See https://support.symfonium.app/t/artist-picture-less-compressed/4447
2024-06-08 13:32:57 -04:00
Deluan
6d526870b7 Fix race condition in external metadata retrieval 2024-06-06 21:01:35 -04:00
Deluan
34678611c0 Small refactoring 2024-06-06 20:15:34 -04:00
Deluan
0f7d6b5bc4 More micro-optimizations 2024-06-06 07:11:43 -04:00
Deluan
939f3eee97 Initialize Index Groups regex just once 2024-06-05 23:00:36 -04:00
Deluan
b4ef1b1e38 Replace gg.If with cmp.Or 2024-06-05 22:48:00 -04:00
Deluan
11bef060a3 Small refactoring 2024-06-05 22:40:22 -04:00
Deluan
abe5690018 Refactor string utilities into its own package str 2024-06-05 22:09:27 -04:00
Deluan
46fc38bf61 Fix tests expectations 2024-06-05 19:54:25 -04:00
dependabot[bot]
6d8d519807 Bump prettier from 3.2.5 to 3.3.0 in /ui (#3069)
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.5 to 3.3.0.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.5...3.3.0)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-03 14:02:49 -04:00
dependabot[bot]
da9cf22b6b Bump github.com/spf13/viper from 1.18.2 to 1.19.0 (#3068)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.18.2 to 1.19.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.18.2...v1.19.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-03 13:25:33 -04:00
Deluan
8c3919d6a0 Simplify dbx wrapper 2024-06-01 15:01:28 -04:00
dependabot[bot]
4df69bd334 Bump github.com/onsi/ginkgo/v2 from 2.17.3 to 2.19.0 (#3054)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.17.3 to 2.19.0.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.17.3...v2.19.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 14:10:56 -04:00
Deluan
ee73a9d297 Small optimization in MediaFiles.ToAlbum() 2024-05-26 14:28:23 -04:00
Caio Cotts
0488fb92cb Fix image stuttering (#3035)
* Fix image stuttering.

* Fix docker publishing for PRs

* Write tests for new square parameter.

* Simplify code for createImage.

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-05-24 20:19:26 -04:00
Deluan
61903facdf Revert isDBInitialized 2024-05-22 16:20:57 -04:00
Drew Weymouth
b6fce0e686 Fix XML marshaling of OpenSubsonic structured lyrics (#3041) 2024-05-22 12:15:14 -04:00
Deluan
f88d3f82da Replace panics with log.Fatals 2024-05-21 17:50:02 -04:00
Deluan
55bff343cd Optimize SQLite3 access. Mainly separate read access from write access.
Based on tips from https://archive.is/Xfjh6#selection-257.0-278.0
2024-05-21 17:19:41 -04:00
dependabot[bot]
68f03d0167 Bump github.com/matoous/go-nanoid/v2 from 2.0.0 to 2.1.0 (#3038)
Bumps [github.com/matoous/go-nanoid/v2](https://github.com/matoous/go-nanoid) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/matoous/go-nanoid/releases)
- [Commits](https://github.com/matoous/go-nanoid/compare/v2.0.0...v2.1.0)

---
updated-dependencies:
- dependency-name: github.com/matoous/go-nanoid/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 10:45:04 -04:00
Deluan
643c763cb4 Show number of results from a query in the logs 2024-05-20 16:21:41 -04:00
Deluan Quintão
67865512c8 Update Ukrainian translations (#3029)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-05-19 22:17:13 -04:00
Deluan
b2ecc1d16f Fix G404 gosec lint error 2024-05-19 21:55:19 -04:00
Deluan
bcaa180fc7 Fix 32 bits builds 2024-05-19 13:03:13 -04:00
Deluan
aeed5a7099 Update caniuse-lite 2024-05-19 12:45:19 -04:00
Deluan
3977ef6e0f Make first WebUI random page stick 2024-05-19 12:35:30 -04:00
Deluan
653b4d97f9 Add missing Test function 2024-05-18 15:05:40 -04:00
Guilherme Souza
98218d045e Deterministic pagination in random albums sort (#1841)
* Deterministic pagination in random albums sort

* Reseed on first random page

* Add unit tests

* Use rand in Subsonic API

* Use different seeds per user on SEEDEDRAND() SQLite3 function

* Small refactor

* Fix id mismatch

* Add seeded random to media_file (subsonic endpoint `getRandomSongs`)

* Refactor

* Remove unneeded import

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-05-18 14:10:53 -04:00
Deluan
a9feeac793 Revert "Always run docker steps (#3034)"
This reverts commit 5d41165b5b.
2024-05-18 11:54:16 -04:00
Deluan
1c0551f4f7 Revert "Fix docker publishing for PRs"
This reverts commit 15c9a0ded3.
2024-05-18 11:54:15 -04:00
Deluan
15c9a0ded3 Fix docker publishing for PRs 2024-05-17 22:45:54 -04:00
Deluan Quintão
5d41165b5b Always run docker steps (#3034) 2024-05-17 22:22:47 -04:00
Deluan
0a763b91d5 Fix lint error 2024-05-17 21:46:59 -04:00
Deluan
4d28d534cc Refactor random.WeightedChooser, unsing generics 2024-05-17 15:45:34 -04:00
Deluan
a7a4fb522c Simplify resources.FS 2024-05-16 22:53:51 -04:00
Deluan
7f52ff72dc Simplify image format detection code 2024-05-16 13:49:40 -04:00
Deluan
8ed07333ed Improve resizeImage code readability 2024-05-16 13:49:40 -04:00
Rob Emery
52235c291d Fix memory leak in CachedGenreRepository (#3031)
that the scanner was run, the ttlcache was also created each time.
This caused (under testing with 166 genres in the database) the
memory consumed by navidrome to 101.18MB over approx 3 days; 96%
of which is in instances of this cache. Swapping to a singleton
has reduced this to down to ~ 2.6MB

Co-authored-by: Rob Emery <git@mintsoft.net>
2024-05-16 12:16:56 -04:00
Fynn Petersen-Frey
de0a08915c fix bug in jukebox: property unavailable (#3024)
* fix bug in jukebox: property unavailable

* fix lint error
2024-05-15 09:48:09 -04:00
Deluan
45c4583f1b Fix race condition 2024-05-13 09:28:19 -04:00
Deluan
478c709a64 Associate main entities with library 2024-05-12 21:37:42 -04:00
Deluan
477bcaee58 Store MusicFolder as a library in DB 2024-05-12 21:37:42 -04:00
Deluan
081ef85db6 Rename MediaFolder to Library 2024-05-12 21:37:42 -04:00
Deluan
6f2643e55e Refactor to use more Go 1.22 features 2024-05-12 20:04:53 -04:00
Deluan
9ee63b39cb Update Go to 1.22.3 2024-05-12 20:04:53 -04:00
Deluan
c556088820 Change dsf mime-type to audio/x-dsf.
Fix #3021
2024-05-12 11:33:50 -04:00
Deluan
78f554721a Revert "Add download link to PR" workflow 2024-05-11 20:40:12 -04:00
Deluan
2c8c87a980 Remove duplicated test 2024-05-11 20:15:02 -04:00
Deluan
3463d0c208 Simplify random.Int64 usage with generics 2024-05-11 20:10:46 -04:00
Deluan
0ae2944073 Refactor random functions 2024-05-11 20:04:21 -04:00
Deluan
30ae468dc1 Uses Unix milliseconds support from standard Go lib 2024-05-11 19:50:30 -04:00
Deluan
ec68d69d56 Refactor cache.HTTPClient 2024-05-11 19:37:12 -04:00
Deluan
955a9b43af Refactor merge.FS 2024-05-11 19:37:12 -04:00
Deluan
56809419c2 Fix "Add download link to PR" workflow 2024-05-11 18:50:46 -04:00
Deluan
3a2a5e961b Add samplingRate to OpenSubsonic responses 2024-05-11 17:57:45 -04:00
Deluan
f3bb022238 Add sampleRate to the DB 2024-05-11 17:57:45 -04:00
Deluan
472324e280 Read sampleRate from audio files 2024-05-11 17:57:45 -04:00
Deluan
ed83c22632 Do not panic if when updatePlaylist is called with a non-existent ID.
Fix #2876
2024-05-11 15:37:50 -04:00
edthu
2fdc1677f7 Add Catppuccin Macchiato Theme (#3014)
* Added Catppuccin Macchiato theme

* fixed index.js formatting
2024-05-11 13:08:51 -04:00
Deluan
80e68dfbcd Bump actions/github-script to v7 2024-05-10 16:00:21 -04:00
Deluan
a9c745839b Bump actions/stale and dessant/lock-threads versions 2024-05-10 15:51:16 -04:00
Deluan
bb96d455f8 Replace sync.WaitGroup with more appropriate errgroup.Group 2024-05-10 15:27:07 -04:00
Deluan
c0885b55db Fix M3U mimetype on Debian Bullseye 2024-05-09 22:26:15 -04:00
Deluan
00cbe4c357 Update Go to 1.22.3 2024-05-09 22:26:15 -04:00
Valeri Sokolov
2b49c7ff76 fix: languageName for Persian (#3011)
"انگلیسی" is "English"
2024-05-09 17:08:43 -04:00
Deluan
09d1fd0658 Simplify normalized AlbumPlayCountMode calc 2024-05-09 08:13:42 -04:00
Deluan
747069b229 Remove unused code 2024-05-09 07:47:32 -04:00
Deluan
885cd345ab Clean up runNavidrome function 2024-05-09 07:44:08 -04:00
Deluan Quintão
c4b05dac28 Make sorting lists by name/title case-insensitive (#2993)
* Make sort by order_* fields case-insensitive.

* Sort internet radios by name case-insensitive
2024-05-09 07:08:15 -04:00
Deluan Quintão
6408dda948 Terminate all MPV instances when stopping Navidrome (#3008)
* Terminate all mpv instances when stopping Navidrome

* Exit trackSwitcher goroutine when terminating

* Remove potential race condition when starting the Playback device

* Fix lint error

* Removed unused and unneeded vars/functions

* Use device short name in log

* Small refactor

* Small nitpick

* Make start functions more uniform
2024-05-09 06:57:24 -04:00
Deluan
677d9947f3 Make dependency injection more consistent 2024-05-08 22:21:38 -04:00
Deluan
a0290587b9 Fix migration package name mismatch 2024-05-08 19:54:48 -04:00
Deluan
eb93136b3f Change default transcodings to a proper typed struct 2024-05-08 17:39:25 -04:00
Deluan
62cc8a2d4b Fix ambiguous column when sorting media_files by created_at.
Fix #3006
2024-05-08 08:24:26 -04:00
Deluan
dd4374cec6 Limit access to Jukebox for admins only (configurable).
Closes #2849
2024-05-07 19:35:43 -04:00
Deluan
86567f5406 Bump Go dependencies 2024-05-07 19:26:02 -04:00
Matthias Schmidt
ff8dca5abe Guard against missing active track (#2996)
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-05-07 19:22:39 -04:00
Matthias Schmidt
b3d70e9264 Persist adjusted volume (#2997)
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-05-07 19:21:35 -04:00
Ludovic Fernandez
4d29184998 Improves golangci-lint configuration and workflow (#3004)
* chore: the default Go version is based on the go.mod

* chore: use linter configuration instead of exclude-rules

* chore: update workflow
2024-05-07 18:52:26 -04:00
Deluan
2470471b2b Pin golangci-lint-action version as a workaround to fix the pipeline.
See https://github.com/golangci/golangci-lint/issues/4695
2024-05-06 21:53:47 +02:00
Deluan
544ae90ec1 Fix CollapsibleComment in PlaylistDetails. Closes #2992 2024-05-02 13:48:10 -04:00
Deluan
aef49cb8d6 Add HTTPSecurityHeaders.CustomFrameOptionsValue option.
Requested in https://github.com/navidrome/navidrome/issues/248#issuecomment-1783768985
2024-05-02 12:35:16 -04:00
Deluan
7c5eec715d Fix typo 2024-05-01 23:09:11 -04:00
Kendall Garner
a4c2232041 Sort repeated lyrics that may be out of order (#2989)
With synchronized lyrics with repeated text, there is not a guarantee that the repeat is in order (e.g. `[00:00.00][00:10.00] a\n[00:05.00]b`).
This change will post-process lyrics with repeated timestamps in one line to ensure that it is always sorted.
2024-05-01 21:54:46 -04:00
Deluan
8f11b991d2 Bump Go dependencies 2024-05-01 20:40:34 -04:00
Deluan
d4a9a9e555 Fix PlaylistTracks's loadAllGenres. Fix #2988 2024-05-01 20:17:42 -04:00
Deluan
a8955f24e0 Fix AlbumPlayCountMode. Closes #2984 2024-05-01 20:05:36 -04:00
Deluan
2c06a4234e Fix int types in OpenSubsonic responses.
Refer to https://support.symfonium.app/t/symfonium-sync-crashes-when-tpos-is-not-an-int/4204
2024-05-01 13:57:11 -04:00
Deluan
7ab7b5df5e Fix signaler on Windows 2024-04-28 18:32:28 -04:00
Deluan
3d9fff36f7 Use signal.NotifyContext 2024-04-28 17:44:11 -04:00
Deluan
31fcab07d2 Refactor loadGenres, remove duplication 2024-04-28 17:04:12 -04:00
Deluan
de90152a71 Refactor DB Album mapping to model.Album 2024-04-28 13:51:57 -04:00
Deluan
27875ba2dd Load mime_types from external file 2024-04-28 12:18:24 -04:00
Deluan
28f7ef43c1 Remove AlbumPlayCountMode from command line options 2024-04-27 20:39:16 -04:00
Deluan
92a98cd558 Add tests for AlbumPlayCountMode, change the calc to match the request from #1032 2024-04-27 15:20:46 -04:00
Deluan
5d50558610 Add tests for AlbumPlayCountMode 2024-04-27 15:07:50 -04:00
vvdveen
8bff1ad512 Add AlbumPlayCountMode config option (#2803)
Closes #1032

* feat(album_repository.go): add kodi-style album playcount option - #1032

Signed-off-by: Victor van der Veen <vvdveen@gmail.com>

* fix format issue and remove reference to kodi (now normalized)

Signed-off-by: Victor van der Veen <vvdveen@gmail.com>

* reduced complexity but added rounding

Signed-off-by: Victor van der Veen <vvdveen@gmail.com>

* Use constants for AlbumPlayCountMode values

---------

Signed-off-by: Victor van der Veen <vvdveen@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-04-27 14:10:40 -04:00
crazygolem
1e96b858a9 Add support for Reverse Proxy auth in Subsonic endpoints (#2558)
* feat(subsonic): Add support for Reverse Proxy auth - #2557

Signed-off-by: Jeremiah Menétrey <superjun1@gmail.com>

* Small refactoring

---------

Signed-off-by: Jeremiah Menétrey <superjun1@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-04-27 13:47:42 -04:00
Deluan
aafd5a952c Bump github.com/spf13/viper from 1.15.0 to 1.18.2 2024-04-26 22:11:43 -04:00
Deluan Quintão
d9cd5efd67 Bump Go dependencies (#2976)
* Fix build

* Bump dependencies
2024-04-26 18:21:10 -04:00
Deluan
affa9c3478 Bump github.com/pressly/goose/v3 from 3.19.2 to 3.20.0 2024-04-26 18:07:06 -04:00
Anna Smith
651a8fdaf9 Fix typo in comment (#2974) 2024-04-26 17:59:39 -04:00
Deluan
f7fc17c0f7 Add OpenSubsonic channelCount 2024-04-26 17:51:04 -04:00
Deluan
f5df948eb1 Fix scrobble error spam in the logs.
Relates to #2831 and #2975
2024-04-26 16:59:14 -04:00
crazygolem
18143fa5a1 Use the RealIP middleware also behind a reverse proxy (#2858)
* Use the RealIP middleware only behind a reverse proxy

* Fix proxy ip source in tests

* Fix test for PR#2087

The PR did not update the test after changing the behavior, but the test still
passed because another condition was preventing the user from being created in
the test.

* Use RealIP even without a trusted reverse proxy

* Use own type for context key

* Fix casing to follow go's conventions

* Do not apply RealIP middleware twice

* Fix IP source in logs

The most interesting data point in the log message is the proxy's IP, but
having the client IP too can help identify integration issues.
2024-04-25 20:43:58 -04:00
Tim
8f9ed1b994 Handling long playlist comments (#2973)
Closes #1737

* wrapping playlist comment in a <Collapse> element

* Extract common collapsible logic into a component

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-04-25 20:28:25 -04:00
dependabot[bot]
cf66594b6d Bump github.com/onsi/gomega from 1.32.0 to 1.33.0 (#2968)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.32.0 to 1.33.0.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.32.0...v1.33.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 17:09:51 -04:00
Deluan
ca005f6457 Include MPV in release Docker image. Refers to #2910 2024-04-21 21:02:36 -04:00
Deluan
6dcfe4d455 Fix typo 2024-04-20 13:16:50 -04:00
Deluan
7871d69adb Allow comments in the NSP file.
See comment https://github.com/navidrome/navidrome/issues/1417#issuecomment-2064731407
2024-04-20 12:50:45 -04:00
Deluan
78182f40d6 Block regular users from changing their own playlists ownership 2024-04-20 12:08:07 -04:00
Deluan
9aeaaa6610 Fix issue in https://github.com/navidrome/navidrome/issues/2767#issuecomment-2065636352 2024-04-19 12:38:02 -04:00
dependabot[bot]
068c1e9a23 Bump golang.org/x/net from 0.21.0 to 0.23.0 (#2962)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.21.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.21.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-19 09:15:08 -04:00
Jonathan
bcec15dc13 Externalize MPV command template (#2948)
* externalise MPVTemplate

* Remove unnecessary comment

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-04-15 21:31:54 -04:00
dependabot[bot]
cf6603e3ec Bump react-icons from 5.0.1 to 5.1.0 in /ui (#2957)
Bumps [react-icons](https://github.com/react-icons/react-icons) from 5.0.1 to 5.1.0.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.0.1...v5.1.0)

---
updated-dependencies:
- dependency-name: react-icons
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-15 14:35:00 -04:00
dependabot[bot]
88d6757121 Bump github.com/pelletier/go-toml/v2 from 2.2.0 to 2.2.1 (#2956)
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Changelog](https://github.com/pelletier/go-toml/blob/v2/.goreleaser.yaml)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.2.0...v2.2.1)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-15 14:34:33 -04:00
Andrew Katsikas
c2f932c21c Fix jukebox mode under Windows (#2774)
* bug(core/playback/mpv): jukebox mode under windows - #2767

Use named pipe for socket path under windows during mpv playback, change function name, unexport function

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - #2767

Fix typo

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Early return for Close on Windows

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Update import and run prettier

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Update function name

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Create track_close files for both platforms and move MpvTrack Close into new file

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Create SocketName function for both platforms, restore name of TempFileName

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Add missing params to SocketName on windows

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* Unexport SocketName, use socketName in NewTrack

---------

Signed-off-by: apkatsikas <apkatsikas@gmail.com>
2024-04-14 13:50:37 -04:00
Deluan
d968f7f530 Remove deprecation warning about notify 2024-04-13 15:27:54 -04:00
dependabot[bot]
5fc78f120c Bump prettier from 3.2.2 to 3.2.5 in /ui (#2844)
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.2 to 3.2.5.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.2...3.2.5)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 15:10:03 -04:00
dependabot[bot]
52dfa97262 Bump @testing-library/jest-dom from 6.2.0 to 6.4.2 in /ui (#2845)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.2.0 to 6.4.2.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.2.0...v6.4.2)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 15:09:53 -04:00
dependabot[bot]
c1eef058a4 Bump follow-redirects from 1.15.4 to 1.15.6 in /ui (#2911)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 15:09:34 -04:00
Deluan
7f551a7932 Add make target to build docker image 2024-04-13 13:29:45 -04:00
oftenoccur
bcb71b85c0 Fix some typos in comments (#2949)
Signed-off-by: oftenoccur <ezc5@sina.com>
2024-04-11 14:58:14 -04:00
Deluan
8720bd154f Ignore formatting diffs when checking for POEditor changes 2024-04-11 14:55:53 -04:00
Cyrille
699be19bb9 Fix a few mistakes in the French translation (#2872)
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-04-10 19:37:08 -04:00
looklose
22cc9e0cd5 Fix function name in comment (#2947)
Signed-off-by: looklose <shishuaiqun@yeah.net>
2024-04-10 12:53:21 -04:00
dependabot[bot]
6e36abdd62 Bump github.com/go-chi/jwtauth/v5 from 5.3.0 to 5.3.1
Bumps [github.com/go-chi/jwtauth/v5](https://github.com/go-chi/jwtauth) from 5.3.0 to 5.3.1.
- [Release notes](https://github.com/go-chi/jwtauth/releases)
- [Commits](https://github.com/go-chi/jwtauth/compare/v5.3.0...v5.3.1)

---
updated-dependencies:
- dependency-name: github.com/go-chi/jwtauth/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-09 20:45:43 -04:00
dependabot[bot]
e98c7374a9 Bump github.com/pelletier/go-toml/v2 from 2.1.1 to 2.2.0
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.1.1 to 2.2.0.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Changelog](https://github.com/pelletier/go-toml/blob/v2/.goreleaser.yaml)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.1.1...v2.2.0)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-09 20:45:27 -04:00
Deluan Quintão
de7f553526 Update Go to 1.22.2 and TagLib to 2.0.1 (#2946) 2024-04-09 19:00:38 -04:00
dependabot[bot]
9cc0cc2e93 Bump github.com/pressly/goose/v3 from 3.18.0 to 3.19.2
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.18.0 to 3.19.2.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.18.0...v3.19.2)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 19:52:34 -04:00
dependabot[bot]
24298605d4 Bump github.com/onsi/ginkgo/v2 from 2.15.0 to 2.17.1
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.15.0 to 2.17.1.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.15.0...v2.17.1)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 19:46:24 -04:00
Deluan
4865d04ec6 Fix DiscTitle OpenSubsonic compatibility. Closes #2929 2024-04-08 19:05:36 -04:00
dependabot[bot]
81770351de Bump github.com/onsi/gomega from 1.31.1 to 1.32.0
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.31.1 to 1.32.0.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.31.1...v1.32.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 19:03:15 -04:00
dependabot[bot]
b6bbba754a Bump golang.org/x/sync from 0.6.0 to 0.7.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.6.0 to 0.7.0.
- [Commits](https://github.com/golang/sync/compare/v0.6.0...v0.7.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 18:57:52 -04:00
deluan
4f6121fae1 Update translations 2024-04-03 07:31:54 -04:00
Kendall Garner
f12dfb485a Expose OpenSubsonic release date for album (#2906)
* [enhancement]: OS expose release date for album, make original optional

* not optional

* remove omitempty
2024-04-03 07:30:01 -04:00
Deluan
e81bf5125f Bump actions versions 2024-04-02 19:37:59 -04:00
dependabot[bot]
a47acb6674 Bump github.com/lestrrat-go/jwx/v2 from 2.0.20 to 2.0.21
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.20 to 2.0.21.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.20...v2.0.21)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx/v2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-26 11:54:28 -04:00
dependabot[bot]
4a15677474 Bump google.golang.org/protobuf from 1.32.0 to 1.33.0
Bumps google.golang.org/protobuf from 1.32.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-26 11:53:51 -04:00
Deluan
859cdda0bd Bump Go dependencies 2024-03-03 21:30:28 -05:00
Deluan
87ecd118bb Bump goose to 3.18.0.
To fix the ambiguous import issue, I used:
go get -u google.golang.org/genproto/googleapis/rpc
2024-03-03 21:27:33 -05:00
Deluan
5abe156777 Logs don't panic when receiving a nil *time.Time 2024-02-18 13:06:01 -05:00
Deluan
fa72aaa462 Move TempFileName to utils 2024-02-18 12:52:06 -05:00
Deluan
6eb13c9f79 Run Test job in ci-goreleaser container 2024-02-18 12:52:06 -05:00
Deluan
b67d1c0830 Show taglib and ffmpeg versions in the log 2024-02-18 12:52:06 -05:00
Deluan
effd588406 Stop using deprecated TagLib method length 2024-02-18 12:52:06 -05:00
Deluan
6f4c55dbde Use new ci-goreleaser (with TagLib 2) 2024-02-18 12:52:06 -05:00
Deluan
176329343a Send Subsonic formatted response on marshalling errors 2024-02-17 10:39:29 -05:00
Deluan
97c7e5daaf Use new slices package from Go standard lib 2024-02-16 22:00:44 -05:00
Deluan
166eb37787 Use Go builtin min/max func 2024-02-16 21:53:16 -05:00
Deluan
f7a4387d0e Bump github.com/jellydator/ttlcache/v2 to v2.11.1 2024-02-16 21:42:22 -05:00
Deluan
71e5b271fb Bump github.com/xrash/smetrics version 2024-02-16 20:52:23 -05:00
Deluan
d51148ea4c Bump github.com/go-chi/chi/v5 to v5.0.12 2024-02-16 20:51:30 -05:00
Deluan
7cb8cc115e Bump github.com/mattn/go-sqlite3 to v1.14.22 2024-02-16 20:50:45 -05:00
Deluan
69d91189c2 Upgrade ginkgo and gomega 2024-02-16 20:49:37 -05:00
Deluan
88063fc189 Upgrade ginkgo and gomega 2024-02-16 20:47:53 -05:00
Deluan
912e144b71 Bump github.com/google/uuid to 1.6.0 2024-02-16 20:46:41 -05:00
Deluan
87484fe7a9 Bump github.com/google/wire to 0.6.0 2024-02-16 20:45:11 -05:00
Deluan
58f64355c2 Bump golang.org/x/exp version 2024-02-16 20:43:12 -05:00
Deluan Quintão
7167e5ac87 Upgrade to Go 1.22 and Node v20 (#2861)
* Remove workaround for missing `context.WithoutCancel` in Go 1.20

* Upgrade to Go 1.22

* Upgrade GitHub Actions

* Upgrade Node to v20
2024-02-16 20:29:16 -05:00
Deluan
d8e1748928 Return 500 in case of Subsonic response marshalling errors 2024-02-16 19:59:24 -05:00
Deluan
9a051967f6 Handle "Infinity" values for ReplayGain. Fix #2862 2024-02-16 18:44:58 -05:00
Deluan
0b2cf30096 Don't swallow marshalling errors in the Subsonic API 2024-02-16 18:43:36 -05:00
Deluan
6d253225de Use order/sort album/artist when sorting tracks in playlists. Fixes #2819 2024-02-15 21:52:00 -05:00
Caio Cotts
bf2bcb1279 Fix null values in DB (#2840)
* Fix album image_files being null.

* Fix small nitpick.

* Use ExecContext instead of Exec.

* Change more columns to not null and set default values.

* Remove columns that don't need to be changed from migration.

* Fix typo.

* Remove unnecessary select statements.

* Remove duplicate code.

* Do not apply changes to radio table.

* Do not apply changes full_text columns and respective indexes.

* Fix musicbrainz columns.

* Rename migration.

* Make ExternalInfoUpdatedAt nullable

* Make Share's timestamps nullable

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-02-07 20:45:08 -05:00
Deluan Quintão
ac4ceab143 Update French translation (#2834)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2024-02-05 20:10:21 -05:00
Deluan
6226741517 Create resources.FS only once 2024-02-03 12:05:19 -05:00
Deluan
79a4d8f6ad Simplify ShortDur code and tests 2024-02-02 21:07:27 -05:00
Deluan Quintão
61257f89d2 Update translations (#2832)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2024-01-30 07:25:42 -05:00
Deluan
1f71e56741 Don't expose Last.fm API Key in the index.html 2024-01-29 21:42:27 -05:00
Kendall Garner
3a9b3452a2 Set rating value to 0 when value is null (#2824) 2024-01-29 06:26:15 -05:00
Deluan
5125558f52 Make Subsonic search query default to "" if not present.
See https://github.com/orgs/music-assistant/discussions/414#discussioncomment-8265985
2024-01-27 20:00:02 -05:00
Deluan
5f9b6b632d Add a "upgrading schema" log message to the DB initialization when there are pending migrations. 2024-01-27 19:44:49 -05:00
Deluan
fa7cc40d23 Add tests for toSQL 2024-01-27 12:16:38 -05:00
caiocotts
58218e6dc4 Fix fields not being sent on getPlaylist.view responses. 2024-01-26 12:41:55 -05:00
Deluan
67c82f524b "Fix" Reddit badge 2024-01-24 20:24:13 -05:00
Deluan
fb7fd21984 Don't add empty TIPL roles 2024-01-24 19:22:25 -05:00
Deluan
a6fc84a2e1 Parse the ID3v2.4 TIPL frame 2024-01-23 20:50:43 -05:00
Deluan
1e5e8be192 Import ID3 sort_* tags 2024-01-23 18:07:11 -05:00
Deluan
fd61b29a84 Small readability improvement in MergeFS tests 2024-01-21 16:20:47 -05:00
Deluan
2b33ef72e3 Remove offset and limit from count queries. Fixes #2443 2024-01-20 22:02:05 -05:00
Deluan
2fb913f5c9 Add log message to try to capture error in #2735 2024-01-20 20:18:59 -05:00
Deluan
6c05493cda Improve some Jukebox error messages 2024-01-20 20:10:32 -05:00
Deluan
3ca4f44118 Simplify default middlewares setup 2024-01-20 19:17:21 -05:00
Deluan
34c29a156f Simplify RealIP middleware setup 2024-01-20 18:58:12 -05:00
dependabot[bot]
b442736a0f Bump connected-react-router from 6.9.1 to 6.9.3 in /ui (#2741)
Bumps [connected-react-router](https://github.com/supasate/connected-react-router) from 6.9.1 to 6.9.3.
- [Release notes](https://github.com/supasate/connected-react-router/releases)
- [Commits](https://github.com/supasate/connected-react-router/compare/v6.9.1...v6.9.3)

---
updated-dependencies:
- dependency-name: connected-react-router
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:42:43 -05:00
dependabot[bot]
90fccf00d1 Bump workbox-cli from 6.5.4 to 7.0.0 in /ui (#2737)
Bumps [workbox-cli](https://github.com/googlechrome/workbox) from 6.5.4 to 7.0.0.
- [Release notes](https://github.com/googlechrome/workbox/releases)
- [Commits](https://github.com/googlechrome/workbox/compare/v6.5.4...v7.0.0)

---
updated-dependencies:
- dependency-name: workbox-cli
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:38:44 -05:00
dependabot[bot]
bcd4a52616 Bump golang.org/x/sync from 0.5.0 to 0.6.0 (#2779)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.5.0 to 0.6.0.
- [Commits](https://github.com/golang/sync/compare/v0.5.0...v0.6.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:37:46 -05:00
dependabot[bot]
84cffa6b94 Bump github.com/prometheus/client_golang from 1.17.0 to 1.18.0 (#2759)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.17.0...v1.18.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:37:01 -05:00
dependabot[bot]
a51b1b25d2 Bump uuid from 8.3.2 to 9.0.1 in /ui (#2740)
Bumps [uuid](https://github.com/uuidjs/uuid) from 8.3.2 to 9.0.1.
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v8.3.2...v9.0.1)

---
updated-dependencies:
- dependency-name: uuid
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:36:14 -05:00
dependabot[bot]
9f317c054b Bump @testing-library/user-event from 14.5.1 to 14.5.2 in /ui (#2757)
Bumps [@testing-library/user-event](https://github.com/testing-library/user-event) from 14.5.1 to 14.5.2.
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.5.1...v14.5.2)

---
updated-dependencies:
- dependency-name: "@testing-library/user-event"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:35:20 -05:00
dependabot[bot]
5f8d01a207 Bump clsx from 2.0.0 to 2.1.0 in /ui (#2758)
Bumps [clsx](https://github.com/lukeed/clsx) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/lukeed/clsx/releases)
- [Commits](https://github.com/lukeed/clsx/compare/v2.0.0...v2.1.0)

---
updated-dependencies:
- dependency-name: clsx
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:35:08 -05:00
dependabot[bot]
8a648d717a Bump github.com/go-chi/chi/v5 from 5.0.10 to 5.0.11 (#2742)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.10 to 5.0.11.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.0.10...v5.0.11)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:34:58 -05:00
dependabot[bot]
a0dc2ee051 Bump github.com/pelletier/go-toml/v2 from 2.0.6 to 2.1.1 (#2760)
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.0.6 to 2.1.1.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Changelog](https://github.com/pelletier/go-toml/blob/v2/.goreleaser.yaml)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.0.6...v2.1.1)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:32:53 -05:00
dependabot[bot]
ffb4de1e27 Bump github.com/unrolled/secure from 1.13.0 to 1.14.0 (#2761)
Bumps [github.com/unrolled/secure](https://github.com/unrolled/secure) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/unrolled/secure/releases)
- [Commits](https://github.com/unrolled/secure/compare/v1.13.0...v1.14.0)

---
updated-dependencies:
- dependency-name: github.com/unrolled/secure
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:31:50 -05:00
dependabot[bot]
e1fc7983a5 Bump golang.org/x/image from 0.14.0 to 0.15.0 (#2778)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.14.0 to 0.15.0.
- [Commits](https://github.com/golang/image/compare/v0.14.0...v0.15.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:31:18 -05:00
dependabot[bot]
2a43f54eb1 Bump follow-redirects from 1.15.2 to 1.15.4 in /ui (#2786)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:30:55 -05:00
dependabot[bot]
f654e92113 Bump github.com/lestrrat-go/jwx/v2 from 2.0.18 to 2.0.19 (#2792)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.18 to 2.0.19.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.18...v2.0.19)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:29:46 -05:00
flyingOwl
dfa453cc4a Add (not)inplaylist operator to smart playlists (#1884)
Closes #1417 

A smart playlist can use the playlist id for filtering. This can be
used to create combined playlists or to filter multiple playlists.

To filter by a playlist id, a subquery is created that will match the
media ids with the playlists within the playlist_tracks table.

Signed-off-by: flyingOwl <ofenfisch@googlemail.com>
2024-01-20 18:22:17 -05:00
Johannes Engl
8f03454312 Make server unix socket file permission configurable via flag UnixSocketPerm (#2763)
* feat(any): Add flag unixsocketperm with default 0017 - #2625

Signed-off-by: johannesengl <hello@johannesengl.com>

* feat(server): Update unix socket file perm based on config - #2625

Signed-off-by: johannesengl <hello@johannesengl.com>

* Fix default value of socket.

* Refactor unix socket file creation.

* Remove misplaced comment

---------

Signed-off-by: johannesengl <hello@johannesengl.com>
Co-authored-by: Caio Cotts <caio@cotts.com.br>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-01-20 14:50:30 -05:00
dependabot[bot]
8570773b90 Bump prettier from 3.1.1 to 3.2.2 in /ui
Bumps [prettier](https://github.com/prettier/prettier) from 3.1.1 to 3.2.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.1.1...3.2.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-18 09:23:10 -05:00
caiocotts
6cff91e17d Use the default import path for jest-dom. 2024-01-17 17:07:43 -05:00
dependabot[bot]
d0df81a8df Bump @testing-library/jest-dom from 5.16.5 to 6.2.0 in /ui
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.16.5 to 6.2.0.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v5.16.5...v6.2.0)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-17 17:07:43 -05:00
dependabot[bot]
75f3ef64e2 Bump react-icons from 4.4.0 to 5.0.1 in /ui
Bumps [react-icons](https://github.com/react-icons/react-icons) from 4.4.0 to 5.0.1.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v4.4.0...v5.0.1)

---
updated-dependencies:
- dependency-name: react-icons
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-17 16:10:00 -05:00
dependabot[bot]
170ac93926 Bump github.com/onsi/ginkgo/v2 from 2.13.2 to 2.14.0
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.13.2 to 2.14.0.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.13.2...v2.14.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-17 15:58:57 -05:00
Deluan
6f7b48202e Make the GetInstance concurrent test more readable 2023-12-28 16:50:07 -05:00
Deluan
6e2be7f95f Don't force a full scan after upgrading the lyrics 2023-12-28 04:55:45 -05:00
Deluan
0d8f8e3afd Optimize Singleton (sometimes a simple lock is a better solution) 2023-12-27 22:12:34 -05:00
Deluan
e50382e3bf Fix ReplayGain values not being retrieved from DB 2023-12-27 21:14:54 -05:00
Kendall Garner
814161d78d Add OS Lyrics extension (#2656)
* draft commit

* time to fight pipeline

* round 2 changes

* remove unnecessary line

* fight taglib. again

* make taglib work again???

* add id3 tags

* taglib 1.12 vs 1.13

* use int instead for windows

* store as json now

* add migration, more tests

* support repeated line, multiline

* fix ms and support .m, .mm, .mmm

* address some concerns, make cpp a bit safer

* separate responses from model

* remove [:]

* Add trace log

* Try to unblock pipeline

* Fix merge errors

* Fix SIGSEGV error (proper handling of empty frames)

* Add fallback artist/title to structured lyrics

* Rename conflicting named vars

* Fix tests

* Do we still need ffmpeg in the pipeline?

* Revert "Do we still need ffmpeg in the pipeline?"

Yes we do.

This reverts commit 87df7f6df7.

* Does this passes now, with a newer ffmpeg version?

* Revert "Does this passes now, with a newer ffmpeg version?"

No, it does not :(

This reverts commit 372eb4b0ae.

* My OCD made me do it :P

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2023-12-27 20:20:29 -05:00
Deluan
130ab76c79 go mod tidy 2023-12-27 13:04:26 -05:00
Deluan
a186a795f6 Omit empty Genre attributes 2023-12-27 12:44:25 -05:00
Deluan
798b03eabd Add "inspect" command to CLI 2023-12-27 12:41:28 -05:00
Deluan
ea7ba22699 Discard duplicated tags 2023-12-26 19:35:14 -05:00
Andrew Katsikas
b4815ecee5 Add TAK support (#2745)
* bug(consts/mime_types): tak-support - 2514

Add tak to mime_types audioFormats

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(scanner): tak-support - 2514

Add tak test fixture file and add fixes for tag_scanner and walk_dir_tree tests

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* Remove comment

---------

Signed-off-by: apkatsikas <apkatsikas@gmail.com>
2023-12-26 18:39:15 -05:00
Deluan
51e07d4cb5 Add log.IsGreaterOrEqualTo, that take into consideration path-scoped log levels 2023-12-25 16:35:16 -05:00
Deluan
03119e5ccf Add more trace log to TagLib Wrapper 2023-12-23 14:10:38 -05:00
Deluan Quintão
15e1394fa3 Implement originalReleaseDate in OpenSubsonic responses. (#2733)
See https://github.com/opensubsonic/open-subsonic-api/pull/80
2023-12-22 21:03:55 -05:00
Deluan
3f349b1b58 Add todo as a reminder to replace min/max in Go 1.22 2023-12-21 19:19:46 -05:00
Deluan
dfcc189cff Replace all utils.Param* with req.Params 2023-12-21 17:41:09 -05:00
Deluan
00597e01e9 Add req.Params to replace utils.Param* 2023-12-21 16:32:37 -05:00
Dany Marcoux
965fc9d9be Remove beep and the files where it was imported (#2731)
Beep isn't needed anymore since we rely on MPV instead.

The changes to `go.mod` and `go.sum` were done with:
```
go get github.com/faiface/beep@none
go mod tidy
```

Signed-off-by: Dany Marcoux <git@dmarcoux.com>
2023-12-21 08:00:31 -05:00
Deluan Quintão
781ff40464 Bump Go version to 1.21.5 (#2729) 2023-12-20 20:02:40 -05:00
Deluan
a6ed0442f2 Name mapDates return values 2023-12-20 16:29:39 -05:00
dependabot[bot]
515efe37f0 Bump @testing-library/user-event from 13.5.0 to 14.5.1 in /ui
Bumps [@testing-library/user-event](https://github.com/testing-library/user-event) from 13.5.0 to 14.5.1.
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v13.5.0...v14.5.1)

---
updated-dependencies:
- dependency-name: "@testing-library/user-event"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-19 13:18:13 -05:00
dependabot[bot]
6c28c111bb Bump @adobe/css-tools from 4.3.1 to 4.3.2 in /ui
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.3.1 to 4.3.2.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-19 12:34:13 -05:00
dependabot[bot]
92a88ad4d9 Bump golang.org/x/crypto from 0.16.0 to 0.17.0 (#2722)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-18 17:45:37 -05:00
dependabot[bot]
4ccc0a92bf Bump jwt-decode from 3.1.2 to 4.0.0 in /ui (#2714)
* Bump jwt-decode from 3.1.2 to 4.0.0 in /ui

Bumps [jwt-decode](https://github.com/auth0/jwt-decode) from 3.1.2 to 4.0.0.
- [Release notes](https://github.com/auth0/jwt-decode/releases)
- [Changelog](https://github.com/auth0/jwt-decode/blob/main/CHANGELOG.md)
- [Commits](https://github.com/auth0/jwt-decode/compare/v3.1.2...v4.0.0)

---
updated-dependencies:
- dependency-name: jwt-decode
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Make jwt-decode a named import.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Caio Cotts <caio@cotts.com.br>
2023-12-18 17:28:42 -05:00
dependabot[bot]
df3de047ca Bump clsx from 1.1.1 to 2.0.0 in /ui
Bumps [clsx](https://github.com/lukeed/clsx) from 1.1.1 to 2.0.0.
- [Release notes](https://github.com/lukeed/clsx/releases)
- [Commits](https://github.com/lukeed/clsx/compare/v1.1.1...v2.0.0)

---
updated-dependencies:
- dependency-name: clsx
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 15:15:00 -05:00
Caio Cotts
86757663d6 Reformat code with Prettier's new rules. 2023-12-18 15:12:24 -05:00
dependabot[bot]
735d670a5b Bump prettier from 2.8.2 to 3.1.1 in /ui
Bumps [prettier](https://github.com/prettier/prettier) from 2.8.2 to 3.1.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/2.8.2...3.1.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 15:12:24 -05:00
dependabot[bot]
30179146c3 Bump deepmerge from 4.2.2 to 4.3.1 in /ui
Bumps [deepmerge](https://github.com/TehShrike/deepmerge) from 4.2.2 to 4.3.1.
- [Changelog](https://github.com/TehShrike/deepmerge/blob/master/changelog.md)
- [Commits](https://github.com/TehShrike/deepmerge/compare/v4.2.2...v4.3.1)

---
updated-dependencies:
- dependency-name: deepmerge
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 14:22:11 -05:00
dependabot[bot]
03a9f22ed9 Bump @material-ui/icons from 4.11.2 to 4.11.3 in /ui
Bumps [@material-ui/icons](https://github.com/mui-org/material-ui/tree/HEAD/packages/material-ui-icons) from 4.11.2 to 4.11.3.
- [Release notes](https://github.com/mui-org/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/v4.11.3/CHANGELOG.md)
- [Commits](https://github.com/mui-org/material-ui/commits/v4.11.3/packages/material-ui-icons)

---
updated-dependencies:
- dependency-name: "@material-ui/icons"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 14:17:25 -05:00
dependabot[bot]
39e92a1918 Bump github.com/mattn/go-sqlite3 from 1.14.18 to 1.14.19
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.18 to 1.14.19.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.18...v1.14.19)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 14:07:07 -05:00
Deluan
421ce91a9e Fix mpvipc dependency 2023-12-17 13:57:15 -05:00
Deluan
12aae5e951 Some cleanup in the jukebox code, specially log messages 2023-12-17 13:15:47 -05:00
Deluan
932152eb7e Change required fields in Subsonic Jukebox endpoint
See discussion here: https://gitlab.com/ultrasonic/ultrasonic/-/issues/1266#note_1621953651
2023-12-17 13:15:47 -05:00
Deluan
0e3175ea17 Better workaround for Go 1.20 missing context.WithoutCancel 2023-12-16 13:33:03 -05:00
Deluan
d3f6b4692d Temporary fix for scan context cancellation for Go 1.20 2023-12-15 07:59:34 -05:00
Deluan
70effa09e8 Don't cancel Scan on context cancellation 2023-12-14 22:52:48 -05:00
Deluan
7ccf685973 Fix PreferSortTags 2023-12-14 21:45:47 -05:00
Deluan
2aef227572 Add context to SQL queries, enabling cancellation 2023-12-14 17:13:09 -05:00
Deluan
d80e1a260b Fix possible authentication bypass 2023-12-13 19:32:05 -05:00
dependabot[bot]
fd4605d7dc Bump github.com/mattn/go-zglob from 0.0.3 to 0.0.4 (#2015)
Bumps [github.com/mattn/go-zglob](https://github.com/mattn/go-zglob) from 0.0.3 to 0.0.4.
- [Release notes](https://github.com/mattn/go-zglob/releases)
- [Commits](https://github.com/mattn/go-zglob/compare/v0.0.3...v0.0.4)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-zglob
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-13 17:27:42 -05:00
Deluan
a6493c4c36 Bump github.com/google/uuid to v1.5.0 2023-12-13 16:47:05 -05:00
Kendall Garner
54597bd575 Allow reverse proxy auth for unix socket (#2701) 2023-12-12 06:06:27 -05:00
Deluan Quintão
ab53313273 Add new PrefSortTags option (#2696) 2023-12-11 20:37:11 -05:00
Deluan
d3669f46a9 go mod tidy 2023-12-11 19:03:27 -05:00
Deluan
d89de9060a Bump Go dependencies 2023-12-11 17:25:14 -05:00
Deluan
ac3668a33e Removed unused diodes package 2023-12-11 17:22:10 -05:00
dependabot[bot]
6d924ad742 Bump github.com/go-chi/jwtauth/v5 from 5.2.0 to 5.3.0 (#2699)
Bumps [github.com/go-chi/jwtauth/v5](https://github.com/go-chi/jwtauth) from 5.2.0 to 5.3.0.
- [Release notes](https://github.com/go-chi/jwtauth/releases)
- [Commits](https://github.com/go-chi/jwtauth/compare/v5.2.0...v5.3.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/jwtauth/v5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-11 13:41:03 -05:00
Deluan
78d557c185 Remove LastFM shared key 2023-12-10 21:11:40 -05:00
Deluan
546aa26a0a Removed duplicated code 2023-12-09 14:11:07 -05:00
dependabot[bot]
fc677f7951 Bump github.com/lestrrat-go/jwx/v2 from 2.0.17 to 2.0.18 (#2684)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.17 to 2.0.18.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.17...v2.0.18)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-09 14:04:56 -05:00
Deluan
aed0309161 Return AlbumID3 in search3 results 2023-12-09 14:01:22 -05:00
Deluan
465cc091b0 Convert internal disc number representation to int 2023-12-09 13:53:38 -05:00
Deluan
2c9035fdd0 Add discTitles to OpenSubsonic responses 2023-12-09 13:53:38 -05:00
Deluan
af7eead037 Add discs to album 2023-12-09 13:53:38 -05:00
Deluan Quintão
0ca0d5da22 Replace beego/orm with dbx (#2693)
* Start migration to dbx package

* Fix annotations and bookmarks bindings

* Fix tests

* Fix more tests

* Remove remaining references to beego/orm

* Add PostScanner/PostMapper interfaces

* Fix importing SmartPlaylists

* Renaming

* More renaming

* Fix artist DB mapping

* Fix playlist updates

* Remove bookmarks at the end of the test

* Remove remaining `orm` struct tags

* Fix user timestamps DB access

* Fix smart playlist evaluated_at DB access

* Fix search3
2023-12-09 13:52:17 -05:00
dependabot[bot]
7074455e0e Bump github.com/onsi/ginkgo/v2 from 2.13.1 to 2.13.2
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.13.1 to 2.13.2.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.13.1...v2.13.2)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-08 21:35:42 -05:00
caiocotts
2f2fbeb009 Fix ld warnings on taglib_wrapper. 2023-12-04 15:19:12 -05:00
Kendall Garner
742fd16a01 Parse more itunes keys, optimize taglib wrapper (#2680)
* parse more itunes keys

* Move special iTunes M4A logic to Go code

* Simplify ASF/WMA tags handling

* Simplify ASF/WMA tags handling even more, moving compilation logic to `metadata` normalizer

* Remove strdups from C++ code, `C.GoString` already duplicates the strings

* reduced set

* remove strdup

* Small nitpick

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-12-03 14:19:16 -05:00
Deluan Quintão
7766ee069c Return http form post extension (OpenSubsonic) (#2676) 2023-12-02 19:46:57 -05:00
Deluan
4cd7c7f39f Fix FileHaunter tests 2023-12-02 19:40:59 -05:00
Deluan
81daee3b9b Fix FileHaunter tests 2023-12-02 18:43:24 -05:00
Deluan
9b434d743f Ignore flaky FileHaunter tests 2023-12-02 18:32:48 -05:00
Deluan
4641dc0b2b Add ReplayGain to OpenSubsonic API Child response 2023-12-02 15:28:44 -05:00
Deluan
812dc2090f Add support for timeOffset in /stream endpoint 2023-12-02 13:10:36 -05:00
Deluan
a9cf54afef Return genres in bookmark endpoints (OpenSubsonic) 2023-12-02 11:36:16 -05:00
Deluan
595186b1b2 Coalesce null annotation values, to better rank them against annotations with value 0 2023-12-02 11:35:54 -05:00
Deluan
cdccdc56c9 Add more OpenSubsonic fields
- isCompilation
- sortName
2023-11-28 21:26:00 -05:00
Deluan
f580c5b8bc Add more OpenSubsonic fields
- mediaType
- musicBrainzId (Child)
2023-11-28 21:12:28 -05:00
deluan
f0e25c251d Update translations 2023-11-28 06:10:03 -05:00
Deluan
abde399e7b Upgrade to Goose 3.15.1 2023-11-27 14:46:44 -05:00
Deluan
1b4483d32b Remove tools.go 2023-11-27 14:06:00 -05:00
Deluan
f7fe8ba938 npx update-browserslist-db@latest 2023-11-27 13:56:16 -05:00
Deluan
f543e7accc Fix getOpenSubsonicExtensions endpoint
Match the current doc: https://opensubsonic.netlify.app/docs/endpoints/getopensubsonicextensions/

openSubsonicExtensions must be an array, not a struct
2023-11-27 13:27:10 -05:00
Deluan Quintão
60a5fbe1fe Optimize search3, by removing OFFSET when paginating (#2655)
* Optimize pagination, removing offset

* For search, don't add `where` clause for empty queries

* Revert "Replace `COUNT(DISTINCT primary_key)` with `COUNT(*)`"

Genres are required as part of the count queries, so filter by genres work

* Optimize search3 query, using order by id if it is a "" query.

Also fix the optimizePagination query logic

* Allow offset optimizer threshold to be configured
2023-11-27 13:06:23 -05:00
Deluan
28dc98dec4 Revert "Replace COUNT(DISTINCT primary_key) with COUNT(*)"
Genres are required as part of the count queries, so filter by genres work
2023-11-25 23:08:20 -05:00
Deluan
8c8e1ea701 Replace COUNT(DISTINCT primary_key) with COUNT(*) 2023-11-25 22:46:15 -05:00
Deluan
b964018cd7 Show SQL errors in queryAll 2023-11-25 13:54:38 -05:00
Deluan
9aa7b80d0d Generalize BreakUp/RangByChunks functions 2023-11-25 12:13:36 -05:00
Deluan
c3efc57259 Use TagLib 1.13.1 for snapshots/releases 2023-11-24 20:35:38 -05:00
Deluan
27a92b05e7 Fixed deprecated GoReleaser options 2023-11-24 18:08:34 -05:00
Deluan
21f1354cd1 Revert "Bump golang.org/x/exp, change slices.SortFunc function call"
This reverts commit 474f32f1
2023-11-24 17:57:22 -05:00
Deluan
069da5d91c Bump Go to 1.21.4 2023-11-24 17:51:36 -05:00
Deluan
69d2ced852 Bump Go dependencies 2023-11-24 16:45:52 -05:00
Deluan
17ac8d25cb Bump dependencies 2023-11-24 16:40:20 -05:00
Deluan
474f32f1b8 Bump golang.org/x/exp, change slices.SortFunc function call 2023-11-24 16:38:47 -05:00
Deluan
ecadcfb403 Make ParamInt generic (any int type) 2023-11-23 13:40:06 -05:00
Caio Cotts
f69c27d146 Return genres in search3 endpoint. 2023-11-21 21:34:03 -05:00
Caio Cotts
bb7186ce2f Fix marshaling for genres. 2023-11-21 21:34:03 -05:00
dependabot[bot]
5d1493e845 Bump @adobe/css-tools from 4.0.1 to 4.3.1 in /ui
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.0.1 to 4.3.1.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-21 08:47:14 -05:00
Deluan
d0fe406800 Fix Go 1.20 build 2023-11-21 08:32:22 -05:00
Deluan
c8fbf6b60e Bump dependencies 2023-11-21 08:22:02 -05:00
deluan
e5bc3ca200 Update translations 2023-11-21 08:15:32 -05:00
tarokeitaro
6d88dd2c66 Add Indonesian Language 2023-11-21 08:06:51 -05:00
caiocotts
eebfbc5381 Revert walk_dir_tree.go back to using the os package. 2023-11-21 07:17:22 -05:00
Deluan
a5dfd2d4a1 Format subsonic response snapshots 2023-11-18 14:43:40 -05:00
Drew Weymouth
7773522803 Expose OpenSubsonic fields Genres, MusicBrainzId, Bpm, Comment (#2597)
* add Genres to subsonic responses

* add genres in GetAlbum response

* add musicBrainzId

* add Bpm and Comment OpenSubsonic fields

* remove omitempty on OpenSubsonic fields

* add custom JSON marshalers to ensure genres attribute is non-nil

* regenerate snapshots to capture now-mandatory fields
2023-11-18 14:40:00 -05:00
Deluan
53607fe114 Publish all new images to Docker Registry 2023-11-16 23:21:20 -05:00
Caio Cotts
fee0f40a52 Bump dependencies 2023-11-16 20:38:45 -05:00
Caio Cotts
9d2aaff8cb Bump golang.org/x/tools from 0.13.0 to 0.15.0 2023-11-16 20:19:29 -05:00
Caio Cotts
2ff4023cce Bump golang.org/x/image from 0.12.0 to 0.14.0 2023-11-16 20:16:25 -05:00
Kendall Garner
79870b1090 Do not empty old artist metadata (#2423) 2023-11-16 19:20:37 -05:00
Kendall Garner
7a858a2db3 Fix external link for artist page if LastFM is missinb but Musicbrainz is not (#2533)
* fix mbz link if lastfm does not exist

* use lastfmUrl field

* fix artist info undefined
2023-11-16 19:07:52 -05:00
dependabot[bot]
9cefaf66a4 Bump github.com/onsi/gomega from 1.29.0 to 1.30.0
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.29.0 to 1.30.0.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.29.0...v1.30.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-16 18:45:21 -05:00
Kendall Garner
3debd31b12 Add more replaygain tests, fix wma (#2356)
* add more replaygain tests, fix wma

* Convert individual specs to a table spec

* Fix pipeline, by commenting incompatible tests

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-11-14 20:25:18 -05:00
Deluan Quintão
24d9fb5b48 Update translations (#2409)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2023-11-14 19:21:26 -05:00
certuna
40841ab917 Small date mapping fix (#2584)
* Update mapping.go

fallback in the case there's no Date tagged but Original Date or Release Date are tagged

* Add tests

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-11-11 17:13:07 -05:00
certuna
bae5fc946b Fix hardcoded IPv4 literals (#2602)
* Update server_test.go

no hardcoded IPv4 literals

* Update package.json

no hardcoded IPv4 literals
2023-11-11 16:46:53 -05:00
Deluan
e055826068 Fix devcontainer for Go 1.21 2023-11-09 18:23:44 -05:00
dependabot[bot]
54bde266b4 Bump github.com/mattn/go-sqlite3 from 1.14.16 to 1.14.18
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.16 to 1.14.18.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.16...v1.14.18)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-07 18:24:08 -05:00
dependabot[bot]
3a7376901b Bump golang.org/x/sync from 0.3.0 to 0.5.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.3.0 to 0.5.0.
- [Commits](https://github.com/golang/sync/compare/v0.3.0...v0.5.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-07 18:19:32 -05:00
dependabot[bot]
de3d870100 Bump github.com/spf13/cobra from 1.7.0 to 1.8.0
Bumps [github.com/spf13/cobra](https://github.com/spf13/cobra) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.7.0...v1.8.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/cobra
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-07 18:08:13 -05:00
certuna
03175e1a9d Use file creation date for Date Added/CreatedAt (#2553)
* Update mapping.go

CreatedAt = BirthTime

* Update metadata.go

Add BirthTime() function

* Update spread_fs.go

Replacing djherbis/atime package with djherbis/times, as times includes the functionality of atime

* Update go.mod

remove djherbis/atime, add djherbis/times

* Update mapping.go

time package not used anymore

* Update go.sum

removed djherbis/atime, added djherbis/times

* Update spread_fs.go

revert to previous, cannot get rid of /atime after all since it's a dependency of /fscache

* Update go.mod

djherbis/times 1.6.0 now released

* Update go.sum

new sums

* Update metadata.go

Inverted if statement, more readable

* Update go.mod

format fix

* Update go.sum

format fix

* Update go.sum

format fix

* Update go.sum

format fix

* Update metadata.go

variable name times -> fileProperties
check for errors

* Update metadata.go

reverse order of error check

* Update metadata.go

typo

* Update metadata.go

https://github.com/navidrome/navidrome/pull/2553#issuecomment-1787967615
2023-11-01 16:41:07 -04:00
Sam Watson
26472f46fe POST endpoint for importing m3u playlists - #2078 (#2273)
* wip: API endpoint for creating playlists from m3u files

* wip: get user id from context

* temporarily disable failing test

* custom logic for playlist route to accomodate m3u content type

* incorporate playlist parsing into existing logic in core

* re-enable test

* fix locally failing test

* Address requested changes.

* Improve ImportFile tests.

* Remove ownerID as a parameter of ImportM3U.

* Write tests for ImportM3U.

* Separate ImportM3U test into two.

* Test OwnerID and playlist Name.

---------

Co-authored-by: Sam Watson <SwatsonCodes@users.noreply.github.com>
Co-authored-by: caiocotts <caio@cotts.com.br>
2023-11-01 14:59:47 -04:00
dependabot[bot]
6bca7531aa Bump @babel/traverse from 7.19.3 to 7.23.2 in /ui
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.19.3 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 17:46:10 -04:00
dependabot[bot]
68d1d5c99f Bump github.com/lestrrat-go/jwx/v2 from 2.0.12 to 2.0.16
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.12 to 2.0.16.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.12...v2.0.16)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 17:27:45 -04:00
dependabot[bot]
db6c46091e Bump github.com/beego/beego/v2 from 2.0.7 to 2.1.3
Bumps [github.com/beego/beego/v2](https://github.com/beego/beego) from 2.0.7 to 2.1.3.
- [Release notes](https://github.com/beego/beego/releases)
- [Changelog](https://github.com/beego/beego/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/beego/beego/compare/v2.0.7...v2.1.3)

---
updated-dependencies:
- dependency-name: github.com/beego/beego/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 17:24:55 -04:00
dependabot[bot]
4cd916bb78 Bump react-router-dom from 5.3.0 to 5.3.4 in /ui
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 5.3.0 to 5.3.4.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/v5.3.4/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 16:55:59 -04:00
dependabot[bot]
c40e83efab Bump github.com/go-chi/chi/v5 from 5.0.8 to 5.0.10
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.8 to 5.0.10.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.0.8...v5.0.10)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 16:44:35 -04:00
Stephan Wahlen
9094f41f25 Improve TopSongs findMatchingTrack by de-prioritizing compilations (#2532)
in reference to https://github.com/navidrome/navidrome/issues/1701
2023-10-31 16:00:53 -04:00
dependabot[bot]
9ff95b6ced Bump github.com/onsi/gomega from 1.27.10 to 1.29.0
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.27.10 to 1.29.0.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.27.10...v1.29.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 15:48:16 -04:00
Kendall Garner
77ace8570c Coalesce genre to null for smart playlist (#2573) 2023-10-31 13:22:57 -04:00
Matthias Schmidt
59f0c487e7 Jukebox cleanup (#2554)
* Fixing typo FFmpegPath -> MPVPath

* Fixing panic by applying afontenot patch

* Using mpv audio-device flag and naming for config and playback
2023-10-17 18:12:48 -04:00
Deluan
2cd4358172 Make Jukebox available to Subsonic clients 2023-09-14 20:15:39 -04:00
dependabot[bot]
248bf232ff Bump github.com/lestrrat-go/jwx/v2 from 2.0.11 to 2.0.12 (#2480)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.11 to 2.0.12.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.11...v2.0.12)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-10 18:06:51 -04:00
dependabot[bot]
b5664ab905 Bump github.com/onsi/ginkgo/v2 from 2.11.0 to 2.12.0 (#2497)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.11.0 to 2.12.0.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.11.0...v2.12.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-10 12:28:43 -04:00
Lukas H
ac7f94e620 Fix text being unreadable with Ligera theme (#2517)
Change MuiFormGroup color to make it readable.
2023-09-10 12:28:21 -04:00
dependabot[bot]
d45f9f172d Bump github.com/google/uuid from 1.3.0 to 1.3.1 (#2489)
Bumps [github.com/google/uuid](https://github.com/google/uuid) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/google/uuid/releases)
- [Changelog](https://github.com/google/uuid/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/uuid/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: github.com/google/uuid
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-10 12:05:58 -04:00
dependabot[bot]
250107d668 Bump golang.org/x/image from 0.9.0 to 0.12.0 (#2507)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.9.0 to 0.12.0.
- [Commits](https://github.com/golang/image/compare/v0.9.0...v0.12.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-10 12:05:28 -04:00
BoniK
64b14db55a Add Korean Language (#2463) 2023-09-10 11:52:18 -04:00
dependabot[bot]
73d1851c0d Bump golang.org/x/tools from 0.9.1 to 0.13.0 (#2516)
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.9.1 to 0.13.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.9.1...v0.13.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-10 11:48:33 -04:00
Matthias Schmidt
1b16e1140f Jukebox mode (#2289)
* Adding cache directory to ignore-list

* Adding jukebox-related config options

* Adding DevEnableJukebox config option pls. dummy server

* Adding types and routers

* Now without panic

* First draft on parsing the action

* Some cleanups

* Adding playback server

* Verify audio device configuration

* Adding debug-build target to have full symbol support

* Adding beep sound library pls some example code. Not working yet

* Play a fixed mp3 on any interface access for testing purposes

* Put action code into separate file, adding stringer, more debug output, prepare structs, validation

* Put action parameter parser code where it belongs

* Have a single Action transporting all information

* User fmt.Errorf for error-generation

* Adding wide playback interface

* Use action map for parsing, stringer instead switch stmt.

* Use but only one switch case and direct dispatch, refactoring

* Add error handling and pushing to client

* send decent errormessage, no internal server error

* Adding playback devices slice and load it from config

* Combine config-verification and structure init

* Return user-specific device

* Separate playback server from device

* Use dataStore to retrieve mediafile by id

* WIP: Playlist and start/stop handling. Doing start/stop the hard way as of now

* WIP: set, start and stop work on one single song. More to come

* Dont need to wait for the end

* Merge jukebox_action.go into jukebox.go

* Remove getParameterAsInt64(). Use existing requiredParamInt() instead

* Dont need to call newFailure() explicitly

* Remove int64, use int instead.

* Add and set action now accept multiple ids

* Kickout copy of childFromMediaFile(). It is not needed here.

* Refactoring devices and playbackServer

* Turn (internal) playback.DeviceStatus into subsonic JukeboxStatus when rendering output. Indexes int64 -> int

* Now we have a position and playing status

* Switching gain to float32, xs:float is defined as 32 bit. Fixing nasty copy/pointer bug

* Now with volume control

* Start working the queue

* Remove user from device interface

* Rename function GetDevice -> GetDeviceForUser to make intention clearer

* Have a nice stringer for the queue

* User Prepared boolean for now to allow pause/unpause

* Skipping works, but without offsets

* Make ChildFromMediaFile public to be used in jukebox get() implementation

* Return position in seconds and implement offset-skip in seconds

* Default offset to 0

* Adding a simple setGain implementation

* Prepare for transcoding AAC

* WIP: transcode to WAV to use beeps wav decoder. Not done yet.

* WIP: out of sheer desparation: convert to MP3 (which works) rather than WAV to troubleshoot issue.

* Use FLAC as intermediate format to play Apple AAC

* A bit of cleanup

* Catching the end-of-stream event for further reactions

* Have a trackSwitching goroutine waiting on channel when track ends

* Move decoder code into own file. Restructure code a bit

* Now with going on to play the next song in the playlist

* Adding shuffle feature

* Implementing remove action

* Cleanup code

* Remove templates for ffmpeg mp3 generation. Not needed anymore.

* Adding some documentation

* Check whether offset into track is in range. Fixing potential remove track bug. Documentation

* Make golangci-lint happy: handling return values

* Adding test suite and example dummy for playback package

* Adding some basic queue tests

* Only use Jukebox.Enabled config option

* Adding stream closing handling

* Pass context.Context to all PlaybackDevice methods

* Remove unneeded function

* Correct spelling

* Reduce visibility of ChildFromMediaFile

* Decomplicate action-parsing

* Adding simple tempfile-based AAC->FLAC transcoding. No parallel reading and writing yet.

* Try to optimize pipe-writing, tempfile-handling and reading. Not done yet.

* Do a synchronous copy of the tempfile. Racecondition detected

* More debugging statements and fixing the play/pause bug. More work needed

* Start the trackSwitcher() with each device once. Return JSON position even if its 0. More debug-output

* Moving all track-handling code into own module

* Fix typo. Do not pass ctx around when not applicable

* WIP: More refactoring, debugging output

* Fix nil pointer

* Repairing MP3 playback by pinning indirect dependencies: hajimehoshi/go-mp3 and hajimehoshi/oto

* Do not forget to cleanup after a skip action

* Make resync with master easy

* Adding missing mocks

* Adding missing error-handling found by linter

* Updating github.com/hajimehoshi/oto

* Removing duplicate function

* Move BEEP-related code into own package

* Juggle beep-related code around as preparation for interface access

* More refactoring for interface separation

* Gather CloseDevice() behind Track interface.

* Adding skeleton, draft audio-interface using mpv.io

* Adding majority of interface commands using messages to mpv socket.

* Adding end-of-stream handling

* MPV: start/stop are working

* postition is given in float in mpv

* Unify Close() and CloseDevice(). Using temp filename for controlling socket

* Wait until control-socket shows up. Cleanup socket in Close()

* Use canceable command. Rename to Executor

* Skipping tracks works now

* Now with actually setting the position

* Fix regain

* Add missing error-handling found by linter

* Adding retry mode on time-pos property getter

* Remove unneeded code on queue

* Putting build-tag beep onto beep files

* Remove deprecated call to rand.Seed()

"As of Go 1.20 there is no reason to call Seed with a random value. Programs that call Seed with a known value to get a specific sequence of results should use New(NewSource(seed)) to obtain a local random generator."

* Using int32 to conform to Subsonic API spec

* Fix merge error

* Minor style changes

* Get username from context

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-09-10 11:25:22 -04:00
Deluan Quintão
f941347cf1 Upgrade to Go 1.21 (#2475)
* Upgrade to Go 1.21

* Remove 'replacements' from goreleaser config
2023-08-09 11:39:49 -04:00
dependabot[bot]
1b5cefdada Bump github.com/onsi/gomega from 1.27.8 to 1.27.9 (#2450)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.27.8 to 1.27.9.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.27.8...v1.27.9)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-22 19:21:39 -04:00
dependabot[bot]
4cf25fc611 Bump github.com/microcosm-cc/bluemonday from 1.0.24 to 1.0.25 (#2449)
Bumps [github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday) from 1.0.24 to 1.0.25.
- [Release notes](https://github.com/microcosm-cc/bluemonday/releases)
- [Commits](https://github.com/microcosm-cc/bluemonday/compare/v1.0.24...v1.0.25)

---
updated-dependencies:
- dependency-name: github.com/microcosm-cc/bluemonday
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-22 19:11:15 -04:00
dependabot[bot]
14ba83ea1b Bump github.com/go-chi/chi/v5 from 5.0.8 to 5.0.10 (#2444)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.8 to 5.0.10.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.0.8...v5.0.10)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-21 19:12:56 -04:00
dependabot[bot]
08f3fd1343 Bump github.com/pressly/goose/v3 from 3.13.1 to 3.13.4 (#2442)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.13.1 to 3.13.4.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.13.1...v3.13.4)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2023-07-21 19:11:59 -04:00
dependabot[bot]
3d66f58725 Bump tough-cookie from 4.1.2 to 4.1.3 in /ui (#2441)
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.1.2 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.1.2...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-21 19:04:32 -04:00
dependabot[bot]
5b1ba3df05 Bump word-wrap from 1.2.3 to 1.2.4 in /ui (#2446)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-21 19:02:10 -04:00
Deluan
a002830775 Fix EnableMediaFileCoverArt option default value 2023-07-10 18:07:58 -04:00
dependabot[bot]
7b600bed05 Bump golang.org/x/tools from 0.10.0 to 0.11.0 (#2432)
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.10.0 to 0.11.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.10.0...v0.11.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-05 16:38:32 -04:00
dependabot[bot]
7d0a1916d8 Bump golang.org/x/image from 0.8.0 to 0.9.0 (#2430)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.8.0 to 0.9.0.
- [Commits](https://github.com/golang/image/compare/v0.8.0...v0.9.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-05 16:34:58 -04:00
dependabot[bot]
c7fe311c7f Bump github.com/go-chi/jwtauth/v5 from 5.1.0 to 5.1.1 (#2427)
Bumps [github.com/go-chi/jwtauth/v5](https://github.com/go-chi/jwtauth) from 5.1.0 to 5.1.1.
- [Release notes](https://github.com/go-chi/jwtauth/releases)
- [Commits](https://github.com/go-chi/jwtauth/compare/v5.1.0...v5.1.1)

---
updated-dependencies:
- dependency-name: github.com/go-chi/jwtauth/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-05 16:31:38 -04:00
dependabot[bot]
4520a34648 Bump github.com/pressly/goose/v3 from 3.11.2 to 3.13.1 (#2428)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.11.2 to 3.13.1.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.11.2...v3.13.1)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-05 16:31:22 -04:00
BenzLeung
3e14c3c4f8 Add support for lyrics tag unsynced lyrics (#2391)
* Add support for lyrics tag `unsynced_lyrics`

* Update metadata.go

* Update metadata.go

resolve lint issue

* format the code with `goimports`

format the code with `goimports`
2023-06-20 09:32:49 -04:00
dependabot[bot]
1e891d6b07 Bump github.com/prometheus/client_golang from 1.15.1 to 1.16.0 (#2408)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.15.1 to 1.16.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.15.1...v1.16.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-19 14:23:36 -04:00
dependabot[bot]
caf9b22d35 Bump golang.org/x/image from 0.7.0 to 0.8.0 (#2407)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.7.0 to 0.8.0.
- [Commits](https://github.com/golang/image/compare/v0.7.0...v0.8.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-19 14:23:01 -04:00
Deluan Quintão
4f8742bcd1 Update translations (#2329)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2023-06-19 12:27:56 -04:00
Deluan
26aa0f4fff Fix typo 2023-06-19 11:19:43 -04:00
Deluan
4898f31f6d Add format target to Makefile 2023-06-19 11:15:51 -04:00
Philipp Wolfer
9da013f339 Submit duration to ListenBrainz (#2405) 2023-06-17 12:27:00 -04:00
Deluan
5af67c78af Upgrade Go dependencies 2023-06-16 22:34:02 -04:00
Philipp Wolfer
c8608956be Fix listenbrainz submission and clarify MusicBrainz recording ID field (#2279)
* Fix MB recording ID parameter name for ListenBrainz submission

This follows the ListenBrainz API documentation.

Fixes #1657

* Rename MediaFile.MbzTrackID to MbzRecordingID

This better reflects the actual data. That the MusicBrainz
recording ID is stored in file metadata as musicbrainz_trackid
is a historical artifact.

* Rename database column mbz_track_id to mbz_recording_id
2023-06-16 18:00:01 -04:00
Deluan
36eda871f6 Fix locale-dependent tests. Closes #2402 2023-06-16 16:38:03 -04:00
David Casado
7c92a73208 Ignore playlists starting with a dot - #2367 (#2390) 2023-06-16 15:55:17 -04:00
Deluan
f5d97823e8 Fix original date (TDOR) mapping for ffmpeg extractor 2023-06-06 19:13:45 -04:00
Deluan
d6083dab6e Re-apply "Refactor walkDirTree to use fs.FS" but remove context cancelation logic.
This reverts commit 6b3b4d83ff.
2023-06-04 15:06:19 -04:00
Deluan
6b3b4d83ff Revert "Refactor walkDirTree to use fs.FS"
This reverts commit 3853c3318f.
2023-06-04 14:13:33 -04:00
Deluan
3853c3318f Refactor walkDirTree to use fs.FS 2023-06-03 22:25:19 -04:00
tomleb
257ccc5f43 Allow configuring cache folder (#2357)
* Set all clients to dev_download for make get-music

* Use multiple TranscodingCache instances in tests

This fixes flaky tests. The issue is that the TranscodingCache object
was being reused in tests from media_stream_Internal_test.go and
media_stream_test.go. If tests from the former was run first, the cache
would be filled up, so that when running tests from the latter, the `NON
seekable` test would fail.

* Allow configuring cache folder

This commit introduces a new configuration option to configure the cache
folder. This allows the cache to be in a separate folder such as
/var/cache/navidrome on Linux distributions.

* Fix tests

* Removed unused test setup code

---------

Co-authored-by: Deluan <deluan@deluan.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2023-06-02 17:14:11 -04:00
Deluan
cec5fb0d6c Fix lint errors 2023-06-02 16:44:12 -04:00
Deluan
3fc4313e89 Move string slice functions to slice package as generic functions 2023-06-02 16:30:20 -04:00
Deluan
c4c99b7f75 Make GroupAlbumReleases false by default 2023-05-31 15:40:20 -04:00
Deluan
a984bbbc7a Make SmartPlaylists to always be seen as changed for Subsonic clients. 2023-05-25 09:14:00 -04:00
Deluan
ba067667c9 Fix date formatting to use UTC 2023-05-24 14:47:51 -04:00
Deluan
e38a690632 Order albums by full original date (this time is for real). Fixes #1452 2023-05-23 09:51:02 -04:00
Deluan
7d0656f44a Order albums by full original date. Fixes #1452 2023-05-22 23:50:16 -04:00
Deluan
11f33ff8b6 Update dependencies 2023-05-22 17:26:49 -04:00
Deluan
611363fca7 Add missing translation 2023-05-20 17:35:09 -04:00
Deluan
85d43d2366 Add tests to date roll-ups 2023-05-19 21:22:23 -04:00
Deluan
8faaa3cf91 Use table specs in getDate tests 2023-05-19 17:03:14 -04:00
Deluan
20462c52a5 Restore album "year" translation string 2023-05-19 15:29:30 -04:00
certuna
52b77e4194 Support for Original Date, Release Date & splitting/grouping of album editions (#2162)
* Update AlbumGridView.js

* Update AlbumDetails.js

* Update AlbumDetails.js

* Create DoubleRangeField.js

* Update and rename DoubleRangeField.js to RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update AlbumGridView.js

* Update AlbumDetails.js

* Update RangeFieldDouble.js

* Update index.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update AlbumDetails.js

* Update RangeFieldDouble.js

* Update AlbumDetails.js

* Update RangeFieldDouble.js

* Update AlbumDetails.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update AlbumDetails.js

* Update AlbumDetails.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update AlbumDetails.js

* Update RangeFieldDouble.js

* Update AlbumDetails.js

* Update en.json

* Update en.json

* Update AlbumDetails.js

* Update RangeFieldDouble.js

* Update AlbumGridView.js

* Update AlbumDetails.js

* Update AlbumSongs.js

* Update ContextMenus.js

* Update SongDatagrid.js

* Update AlbumSongs.js

* Update SongDatagrid.js

* Update SongDatagrid.js

* Update SongDatagrid.js

* Update AlbumSongs.js

* Update SongList.js

* Update playlist_track_repository.go

* Update 20230113000000_release_year.go

* Update PlayButton.js

* Update mediafile_repository.go

* Update album.go

* Update playlist_track_repository.go

* Update playlist_track_repository.go

* Update SongDatagrid.js

* Update 20230113000000_release_year.go

* Update SongDatagrid.js

* Update AlbumSongs.js

* Update SongDatagrid.js

* Update SongDatagrid.js

* Update SongDatagrid.js

* Update SongDatagrid.js

* Update AlbumDetails.js

* Update AlbumSongs.js

* Update AlbumSongs.js

* Update RangeFieldDouble.js

* Update SongDatagrid.js

* Update 20230113000000_release_year.go

* Update 20230113000000_release_year.go

* Update 20230113000000_release_year.go

* Update 20230113000000_release_year.go

* Update AlbumSongs.js

* Update AlbumSongs.js

* Update mapping.go

* Update RangeFieldDouble.js

* Update AlbumGridView.js

* Update AlbumSongs.js

* Update en.json

* Update SongDatagrid.js

* Update SongDatagrid.js

* Update metadata.go

* Update mapping.go

* Update AlbumDetails.js

* Update AlbumGridView.js

* Update RangeFieldDouble.js

* Update mapping.go

* Update metadata.go

* Update mapping.go

* Update AlbumDetails.js

* Update 20230113000000_release_year.go

* Update AlbumDetails.js

* Update en.json

* Update configuration.go

* Update mapping.go

* Update configuration.go

* Update mediafile.go

* Update metadata.go

* Update RangeFieldDouble.js

* Update 20230113000000_release_year.go

* Update configuration.go

* Update mapping.go

* Update mediafile.go

* Update mapping.go

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update RangeFieldDouble.js

* Update 20230113000000_release_year.go

* Update AlbumDetails.js

* Update RangeFieldDouble.js

* Update mapping.go

* Update metadata.go

* Update album.go

* Update mediafile.go

* Update mediafile.go

* Update album.go

* Update fields.go

* Update mediafile_repository.go

* Update playlist_track_repository.go

* Update AlbumSongs.js

* Update SongDatagrid.js

* Update PlayButton.js

* Update SongList.js

* Update ContextMenus.js

* Update SongDatagrid.js

* Update metadata.go

* Update ArtistShow.js

* Update mapping.go

* Update configuration.go

* Update mapping.go

* Update metadata.go

* Update metadata.go

* Update mapping.go

* Update metadata.go

* Update metadata.go

* Update mapping.go

* Update 20230113000000_release_year.go

* Update 20230113000000_release_year.go

* Update mapping.go

* Update metadata.go

* Update metadata.go

* Update album.go

* Update mediafile.go

* Update AlbumDetails.js

* Update AlbumSongs.js

* Update album.go

* Update mediafile.go

* Update metadata.go

* Update mediafile.go

* Update 20230113000000_release_year.go

* Update 20230113000000_release_year.go

* Update album.go

* Update mediafile.go

* Update RangeFieldDouble.js

* Update AlbumDetails.js

* Update AlbumGridView.js

* Update en.json

* Update AlbumGridView.js

* Update RangeFieldDouble.js

* Update and rename 20230113000000_release_year.go to 20230113000000_release_date.go

* Update album.go

* Update mediafile.go

* Update fields.go

* Update playlist_track_repository.go

* Update mediafile_repository.go

* Update mapping.go

* Update metadata.go

* Update mapping.go

* Update SongDatagrid.js

* Update RangeFieldDouble.js

* Update index.js

* Update ContextMenus.js

* Update PlayButton.js

* Create FormatDate.js

* Update SongList.js

* Update AlbumDetails.js

* Update AlbumSongs.js

* Update AlbumSongs.js

* Update en.json

* Update AlbumDetails.js

* Update album.go

fixed conflict I think?

* Update mediafile.go

fixed conflict

* Format with goimports

* Update SongDatagrid.js

only show Cat # in desktop view

* Update metadata_internal_test.go

* Update metadata_test.go

* Delete test.mp3

* Add files via upload

mp3 test file with Date, Original Date and Release Date

* Update metadata_test.go

* Update metadata_test.go

* Update metadata_test.go

* Update metadata_test.go

* Update taglib_test.go

* Delete test.mp3

* Add files via upload

file with replaygain & dates

* Update AlbumGridView.js

* Update AlbumDetails.js

* Update AlbumSongs.js

* Update ContextMenus.js

* Update FormatDate.js

* Update PlayButton.js

* Update RangeFieldDouble.js

* Update SongDatagrid.js

* Update AlbumSongs.js

* Update SongDatagrid.js

* Update AlbumSongs.js

* Fix formatting

* Update mapping.go

* Update AlbumSongs.js

* Update SongDatagrid.js

* Update SongDatagrid.js

prettier

* Create RangeDoubleField.js

rename of RangeFieldDouble.js

* Update AlbumGridView.js

RangeFieldDouble -> RangeDoubleField

* Update mediafile.go

AllOrNothing() -> allOrNothing()

* Update metadata_internal_test.go

getYear -> getDate

* Update AlbumDetails.js

wrote suggested changes

* Update en.json

Editions -> Releases & fixed the field name

* Update configuration.go

Rename Editions -> Releases

* Update 20230113000000_release_date.go

Editions -> Releases

* Update album.go

Editions -> Releases

* Update mediafile.go

Editions -> Releases

* Update AlbumDetails.js

Editions -> Releases

* Update AlbumSongs.js

Editions -> Releases

* Update RangeDoubleField.js

Editions -> Releases

* Update SongDatagrid.js

Editions -> Releases

* Update index.js

FormatFullDate and RangeDoubleField

* Rename FormatDate.js to FormatFullDate.js

* Delete RangeFieldDouble.js

* Update mediafile.go

AllOrNothing -> allOrNothing

* Update mapping.go

Editions -> Releases

* Update AlbumDetails.js

prettier

* Update SongDatagrid.js

showReleaseRow -> showReleaseDivider

* Update AlbumSongs.js

showReleaseRow -> showReleaseDivider for clarity

* Update and rename 20230113000000_release_date.go to 20230515184510_add_release_date.go

- rename the migration file
- fixed the import to goose/v3
- additional db fields for original date & year

* Update 20230515184510_add_release_date.go

* Update fields.go

* Update album.go

* Update mediafile.go

* Update mapping.go

* Update AlbumDetails.js

* Update en.json

* Update AlbumDetails.js

* Update AlbumDetails.js

now hopefully prettier

* Update mapping.go

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-05-19 15:27:47 -04:00
Deluan
010ba0d15c Use table specs in ReplayGain tests.
Also use test.mp3 file from Release Date PR, trying to fix a conflict.
2023-05-19 14:49:15 -04:00
Zane van Iperen
9b7fac5147 Update default transcoding commands (#2325)
Changes the default transcoding commands to only use the first audio
stream, instead of the first arbitrary stream.

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2023-05-19 10:49:29 -04:00
Deluan
be12c12b28 Remove unused Badge component from ActivityPanel icon 2023-05-17 16:29:19 -04:00
Kendall Garner
a19a643c65 Manually add replaygain tags for m4a (#2346)
* manually add replaygain tags for m4a

* Add replaygain tests for m4a, mp4, ogg

* add new valye for bitrate
2023-05-17 16:00:16 -04:00
Deluan
f9b060af18 Removed onBackdropClick deprecated property 2023-05-17 15:48:22 -04:00
Deluan
a3d78e95f2 Fix Monokai theme. Closes #2353 2023-05-17 15:36:30 -04:00
Deluan
d85b06332c Fix build 2023-05-17 13:49:45 -04:00
Deluan
bfa10cab62 Upgrade to Node v18 2023-05-17 13:41:36 -04:00
Deluan
08fcb430e6 Upgrade React-Admin to 3.19.12 2023-05-17 13:18:58 -04:00
Deluan
5d02df62d0 Fix eslint error 2023-05-17 11:57:43 -04:00
Deluan
c3a2e084b3 Update caniuse-lite 2023-05-17 11:54:22 -04:00
Deluan
4296741ec0 Simplify EventStream handling 2023-05-17 11:53:09 -04:00
Deluan
6bee4ed147 Sanitize filenames inside zip files. Fixes #1763 2023-05-16 18:34:15 -04:00
Deluan
e62c3edc1c Revert: Change fix formatting command 2023-05-16 12:34:09 -04:00
Deluan
0a08d0af3b Change fix formatting command 2023-05-16 12:31:09 -04:00
Deluan
ad513354b9 Disable POEditor import job in forks 2023-05-16 10:33:06 -04:00
dependabot[bot]
a70b81f931 Bump github.com/onsi/ginkgo/v2 from 2.9.4 to 2.9.5 (#2352)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.9.4 to 2.9.5.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.9.4...v2.9.5)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-15 15:12:31 -04:00
dependabot[bot]
0d920c7832 Bump github.com/prometheus/client_golang from 1.14.0 to 1.15.1 (#2342)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.14.0 to 1.15.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.14.0...v1.15.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-13 14:34:11 -04:00
dependabot[bot]
957a73e052 Bump github.com/mileusna/useragent from 1.2.1 to 1.3.2 (#2319)
Bumps [github.com/mileusna/useragent](https://github.com/mileusna/useragent) from 1.2.1 to 1.3.2.
- [Release notes](https://github.com/mileusna/useragent/releases)
- [Commits](https://github.com/mileusna/useragent/compare/v1.2.1...v1.3.2)

---
updated-dependencies:
- dependency-name: github.com/mileusna/useragent
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-13 14:32:01 -04:00
dependabot[bot]
abc418eaa2 Bump github.com/onsi/ginkgo/v2 from 2.9.2 to 2.9.4 (#2343)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.9.2 to 2.9.4.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.9.2...v2.9.4)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-13 14:28:27 -04:00
dependabot[bot]
1128322011 Bump golang.org/x/tools from 0.8.0 to 0.9.1 (#2350)
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.8.0 to 0.9.1.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.8.0...v0.9.1)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-13 14:28:05 -04:00
dependabot[bot]
2e479defd5 Bump github.com/go-chi/httprate from 0.7.1 to 0.7.4 (#2320)
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.7.1 to 0.7.4.
- [Release notes](https://github.com/go-chi/httprate/releases)
- [Commits](https://github.com/go-chi/httprate/compare/v0.7.1...v0.7.4)

---
updated-dependencies:
- dependency-name: github.com/go-chi/httprate
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-13 14:24:37 -04:00
dependabot[bot]
8311a7f215 Bump golang.org/x/sync from 0.1.0 to 0.2.0 (#2344)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.1.0 to 0.2.0.
- [Commits](https://github.com/golang/sync/compare/v0.1.0...v0.2.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-13 14:23:40 -04:00
dependabot[bot]
6ec8f78076 Bump github.com/pressly/goose/v3 from 3.10.0 to 3.11.2 (#2341)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.10.0 to 3.11.2.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/.goreleaser.yml)
- [Commits](https://github.com/pressly/goose/compare/v3.10.0...v3.11.2)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-13 14:23:17 -04:00
Logan Marchione
3e879d2a8c Add K8s manifest (#2330)
* Add K8s manifest

* Update README.md
2023-04-29 16:14:44 -04:00
Jeff Henson
6d3d005fca Allow the setrlimit syscall - #1961 (#2333)
This appears to be used by newer go versions and navidrome fails to
start unless it's allowed.

Signed-off-by: Jeff Henson <jeff@henson.io>
2023-04-27 21:30:43 -04:00
Deluan
c12510d6e2 Update README 2023-04-11 14:00:44 -04:00
Deluan
0bd73bd3f4 Better GH Action names 2023-04-11 09:16:25 -04:00
Deluan
8c120ee3c9 Better GH Action names 2023-04-11 09:15:08 -04:00
Deluan
9590b3c25d Use the highest resolution artist image from Spotify 2023-04-10 15:34:22 -04:00
Deluan
4887c33053 Bump golang.org/x packages 2023-04-10 14:07:12 -04:00
Subhajit Ghosh
da21acba92 Give page the right lang attribute (#2299)
* Fixed issue no #2174

Signed-off-by: Subhajit Ghosh <subhajitstd07@gmail.com>

* Fixed issue no #2174

---------

Signed-off-by: Subhajit Ghosh <subhajitstd07@gmail.com>
2023-04-08 13:39:59 -04:00
Deluan Quintão
9154e44eb4 Add initial support for OpenSubsonic. (#2302) 2023-04-08 13:25:37 -04:00
Deluan Quintão
2e01063429 Update translations (#2198)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2023-04-06 22:09:49 -04:00
Deluan
597e5abed6 Fix push develop to Docker Hub 2023-04-06 20:11:35 -04:00
Deluan Quintão
92994efe48 Publish docker images to ghcr.io (#2298)
* Publish all images (including PRs) to GHCR, only releases and `develop` to Docker Hub
2023-04-06 19:53:31 -04:00
Deluan
9628b1389d Add help msg for JS formatting errors 2023-04-06 11:45:32 -04:00
Deluan
347424009d Show Player name, not client, in mobile view. Fix #1659. 2023-04-05 22:48:33 -04:00
Deluan
ecac74c2bd Fix getSongsByGenre pagination. Fix #1640 2023-04-05 22:39:32 -04:00
Deluan
ddfde7bfc8 Run lint on latest Go 1.20.x 2023-04-04 19:13:24 -04:00
Deluan
96c50d369a Upgrade to Go 1.20.3 and GoRelease 1.16.1 2023-04-04 19:10:03 -04:00
Deluan
310c816cdd Use Go 1.20 for local cross-compilation 2023-04-04 15:33:42 -04:00
Deluan
bd402fb2a8 Fix IntelliJ warning 2023-04-04 13:01:32 -04:00
dependabot[bot]
8bb141b730 Bump github.com/spf13/cobra from 1.6.1 to 1.7.0 (#2293)
Bumps [github.com/spf13/cobra](https://github.com/spf13/cobra) from 1.6.1 to 1.7.0.
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.6.1...v1.7.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/cobra
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-04 11:04:18 -04:00
Deluan
f25b91b4d8 Remove any previous UNIX socket file 2023-04-04 11:03:37 -04:00
dependabot[bot]
f959701d9d Bump github.com/onsi/gomega from 1.27.5 to 1.27.6 (#2292)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.27.5 to 1.27.6.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.27.5...v1.27.6)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-04 10:55:36 -04:00
Deluan
61dd8d55ca Fix data race in scanner 2023-04-04 10:51:43 -04:00
Deluan
bbb9461000 Increase max Server-Sent Events' ID 2023-04-04 10:46:57 -04:00
Deluan
95016f687e Fix SQL migrations 2023-04-04 10:45:55 -04:00
Deluan
c3cc7dee01 Enable SQL migrations 2023-04-04 10:30:28 -04:00
Deluan
7847f19c9d Upgrade goose 2023-04-04 10:05:31 -04:00
dependabot[bot]
7a0df4429e Bump github.com/onsi/gomega from 1.27.5 to 1.27.6 (#2288)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.27.5 to 1.27.6.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.27.5...v1.27.6)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-03 21:01:39 -04:00
Deluan
6a8d2dc87d Only use valid images for artist.* artwork 2023-04-03 18:07:15 -04:00
Deluan
de816e8e5d Fix lint error 2023-04-03 11:15:46 -04:00
Deluan
b22d0366d5 Use channels for EventStream instead of diodes 2023-04-03 10:51:24 -04:00
Deluan
fea2de8f90 Add Galician translation. 2023-04-02 18:58:44 -04:00
Deluan
d6dd0aaae7 Close SSE connection on write error 2023-04-02 18:40:58 -04:00
Fadeeeeeeee
458017b112 Update Chinese translations (#2260)
* Update Chinese translations

* Update Chinese translations

* Update Chinese translations
2023-04-02 18:40:48 -04:00
Deluan
e6bfa2bb0b Convert our usage of go-diodes into a simplified, generic version 2023-04-01 21:53:45 -04:00
Deluan
1c7fb74a1d Fix writeEvents race condition.
This required removing the compress middleware from the /events route.
2023-04-01 20:54:15 -04:00
Deluan
83ae2ba3e6 Fix race condition 2023-04-01 18:40:37 -04:00
Joakim Repomaa
2ccc5bc941 Implement artist art priority (#2266)
* implement artist art priority

* add tests
2023-03-30 18:28:05 -04:00
Deluan
406554f1c4 Remove some tools from dependencies, reducing the modules dependencies 2023-03-30 15:33:47 -04:00
Deluan
e89cdf6199 Fix flaky tests 2023-03-30 09:25:18 -04:00
Deluan
cf804a52ef Add support for listening on Unix socket.
For that to work, specify the config option `Address` with `unix:/path/to/socket/file`.

Closes #1477
2023-03-29 16:05:59 -04:00
Deluan
628fd69d3d Fix race condition 2023-03-29 15:17:34 -04:00
Deluan
1d00d1e986 Fix writeEvent function.
It would not send anything if the `ResponseWriter` was not a `http.Flusher`, and it was leaking channels with `time.After`
2023-03-29 15:04:40 -04:00
Deluan
607c4067b8 Show translation changes on pipeline 2023-03-29 13:03:37 -04:00
Deluan
e3079d81ea More tests 2023-03-27 20:36:23 -04:00
Deluan
3bedd89c17 Bump dependencies 2023-03-27 14:48:20 -04:00
dependabot[bot]
57829bfa4c Bump github.com/lestrrat-go/jwx/v2 from 2.0.8 to 2.0.9 (#2282)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.8 to 2.0.9.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.8...v2.0.9)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-27 14:47:32 -04:00
Deluan
b998c05ca0 Some refactorings 2023-03-26 21:28:37 -04:00
Deluan
05d381c26f Add more middleware tests 2023-03-26 21:28:36 -04:00
zayedalsaidi
59a9c056b4 Add Arabic translation (#2277) 2023-03-26 19:56:59 -04:00
Deluan
0de81b8352 Bump caniuse-lite 2023-03-26 19:38:09 -04:00
Deluan
91785ecf36 Add tests for core.Archiver 2023-03-26 19:34:12 -04:00
Deluan
65eeb5ec1a Add tests for serverAddressMiddleware 2023-03-26 13:29:57 -04:00
Julien Voisin
17e0cd5504 Shuffle the tests, just in case (#2272) 2023-03-22 20:12:12 -04:00
Deluan
3a6d2dcd49 More log redaction 2023-03-21 11:16:00 -04:00
Deluan
183b462fed Fix zip comments in Share downloads. 2023-03-21 10:34:04 -04:00
Deluan
16fc4eb792 Fix missing extensions in Share downloads.
See https://github.com/navidrome/navidrome/pull/2246#issuecomment-1476996397
2023-03-21 10:31:00 -04:00
dependabot[bot]
6fee744d99 Bump github.com/onsi/gomega from 1.27.3 to 1.27.4 (#2268)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.27.3 to 1.27.4.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.27.3...v1.27.4)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-20 14:15:32 -04:00
dependabot[bot]
74d5c7bc82 Bump github.com/golangci/golangci-lint from 1.51.2 to 1.52.0 (#2270)
Bumps [github.com/golangci/golangci-lint](https://github.com/golangci/golangci-lint) from 1.51.2 to 1.52.0.
- [Release notes](https://github.com/golangci/golangci-lint/releases)
- [Changelog](https://github.com/golangci/golangci-lint/blob/master/CHANGELOG.md)
- [Commits](https://github.com/golangci/golangci-lint/compare/v1.51.2...v1.52.0)

---
updated-dependencies:
- dependency-name: github.com/golangci/golangci-lint
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-20 14:15:18 -04:00
dependabot[bot]
880fc9e195 Bump github.com/Masterminds/squirrel from 1.5.3 to 1.5.4 (#2269)
Bumps [github.com/Masterminds/squirrel](https://github.com/Masterminds/squirrel) from 1.5.3 to 1.5.4.
- [Release notes](https://github.com/Masterminds/squirrel/releases)
- [Commits](https://github.com/Masterminds/squirrel/compare/v1.5.3...v1.5.4)

---
updated-dependencies:
- dependency-name: github.com/Masterminds/squirrel
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-20 14:15:01 -04:00
Xidorn Quan
1430aa108d Update play_date on scrobble only when newer - #2262 (#2263)
* fix(persistence): Update play_date on scrobble only when newer - #2262

Signed-off-by: Xidorn Quan <me@upsuper.org>

* expand iff

---------

Signed-off-by: Xidorn Quan <me@upsuper.org>
2023-03-18 18:28:01 -04:00
Deluan
673880d661 Add option to load TLS cert/key, and use HTTPS 2023-03-17 16:32:13 -04:00
Deluan
7ea111322b Don't pump the volume up to 100% if it is not in a mobile device. Fix #2255
This detection method is not bullet-proof, but should work for now.

Ref: https://stackoverflow.com/a/3540295
2023-03-16 17:25:07 -04:00
Deluan
377e7ebd52 Disable share downloading when EnableDownloads is false.
Fixes https://github.com/navidrome/navidrome/pull/2246#issuecomment-1472341635
2023-03-16 13:11:26 -04:00
Deluan
23c483da10 Only freezes issues/prs after 120 days 2023-03-15 17:53:54 -04:00
Deluan
c380139606 Fix lint 2023-03-15 13:10:14 -04:00
Deluan
63fbccf5a9 Enable memory profiling 2023-03-15 12:43:25 -04:00
Deluan
1f6ec1d9f5 Add pprof endpoint, disabled by default 2023-03-15 10:56:16 -04:00
dependabot[bot]
cad8156353 Bump webpack from 5.74.0 to 5.76.1 in /ui (#2256)
Bumps [webpack](https://github.com/webpack/webpack) from 5.74.0 to 5.76.1.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.74.0...v5.76.1)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-15 09:13:22 -04:00
Deluan Quintão
f7d4fcdcc1 Convert all Subsonic API ints to int32 as per specification (#2252)
* Fix Genre

* Fix ArtistID3

* Fix AlbumID3

* Fix Child

* Fix NowPlayingEntry

* Fix Playlist

* Fix Share

* Fix User

* Fix Artist

* Fix Directory

* Fix Error
2023-03-14 09:48:52 -04:00
Deluan Quintão
002cb4ed71 Update README.md 2023-03-13 19:34:47 -04:00
Deluan Quintão
e13eaebbde Update README.md 2023-03-13 19:32:13 -04:00
dependabot[bot]
539c0faedb Bump github.com/onsi/ginkgo/v2 from 2.9.0 to 2.9.1 (#2251)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.9.0 to 2.9.1.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.9.0...v2.9.1)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-13 14:42:40 -04:00
Moink
4ccb6ccb09 Update Chinese translations (#2250) 2023-03-12 20:24:31 -04:00
Deluan
ec0eb2866b Hide Love button on Artist Page when EnableFavourites=false. Fix #2245 2023-03-10 23:34:02 -05:00
Deluan
b520d8827a Add download button in the SharePlayer 2023-03-10 23:33:29 -05:00
Deluan
a7d3e6e1f1 Add option to allow share to be downloaded 2023-03-10 23:33:29 -05:00
Deluan
a22eef39f7 Add share download endpoint 2023-03-10 23:33:29 -05:00
Torsten Curdt
50d9838652 Add docker compose examples, with traefik or caddy and without, fixes #476 (#2240)
* add docker compose examples, with traefik or caddy and without, fixes #476

* ignore the docker-compose in root, but not the one in contrib
2023-03-10 18:57:09 -05:00
Deluan
016454c217 Bump golangci-lint version 2023-03-10 17:46:05 -05:00
Deluan
41a5db72e7 Update more dependencies 2023-03-10 17:31:13 -05:00
Deluan
6e6ec58429 Update sanitize and golang.org/x dependencies 2023-03-10 17:21:08 -05:00
Deluan
c88e1baa7c Make playlist tracks match case-insensitive. Fix #1720 2023-03-10 12:29:38 -05:00
Deluan
e16e3d2e7b Fix pipeline. 2023-03-09 22:25:56 -05:00
Deluan
339a6239fd Ignore Recycle Bins in Windows. Fix #1074 2023-03-09 22:14:58 -05:00
Deluan
47f15ccbc3 Make AlbumArtists clickable in AlbumSongs view. Fixes #1627 2023-03-09 18:04:07 -05:00
Deluan
9667f3cd48 Add file path to toggleable columns in SongList view. Fix #1719 2023-03-09 17:47:20 -05:00
Deluan
5773fa0349 Fix discussions links 2023-03-08 14:14:42 -05:00
Deluan
527c378c41 Add feature request link to About dialog 2023-03-08 12:41:51 -05:00
Deluan
caa0788853 Fine tune issue templates 2023-03-08 12:27:28 -05:00
Deluan
40b14e6d81 Add log-output to lock-threads bot 2023-03-06 20:12:46 -05:00
Deluan
becd50eb68 Remove debug-only option from stale bot 2023-03-06 20:08:02 -05:00
Deluan
15b5aa9143 Add stale/lock-threads bot 2023-03-06 20:01:42 -05:00
Deluan
7987d982cf Fix pipeline's lint error message 2023-03-06 19:38:20 -05:00
Deluan
1dd074bbb4 Add new issue templates 2023-03-06 17:15:36 -05:00
Deluan
7eac9d2bbe Bump dependencies 2023-03-05 21:09:45 -05:00
dependabot[bot]
362d8c50fe Bump github.com/onsi/gomega from 1.26.0 to 1.27.1 (#2204)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.26.0 to 1.27.1.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.26.0...v1.27.1)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-05 20:25:16 -05:00
dependabot[bot]
01c604ba7b Bump github.com/stretchr/testify from 1.8.1 to 1.8.2 (#2216)
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.8.1 to 1.8.2.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.8.1...v1.8.2)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-05 20:23:36 -05:00
dependabot[bot]
2c129a2890 Bump golang.org/x/image from 0.0.0-20191009234506-e7c1f5e7dbb8 to 0.5.0 (#2217)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.0.0-20191009234506-e7c1f5e7dbb8 to 0.5.0.
- [Release notes](https://github.com/golang/image/releases)
- [Commits](https://github.com/golang/image/commits/v0.5.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-05 20:23:04 -05:00
Deluan
5fc4076aec Fix translation key 2023-02-16 21:05:11 -05:00
Deluan
d303ad2676 Bump dependencies 2023-02-15 22:46:56 -05:00
Deluan
c4a68c8a0a Fix build pipeline 2023-02-15 22:27:16 -05:00
Deluan
ad9ce98cc2 Use GoLang 1.20.1 in pipeline 2023-02-15 22:21:50 -05:00
Deluan
a134b1b608 Use sync/atomic package, now that we are at Go 1.19 2023-02-15 21:21:59 -05:00
Deluan
6dce4b2478 Remove custom atomic.Bool, we are now at Go 1.19 2023-02-15 21:18:24 -05:00
Deluan
10108c63c9 Allow BaseURL to contain full server url, including scheme and host. Fix #2183 2023-02-15 21:13:38 -05:00
Deluan
aac6e2cb07 Add path to cookies. Fix #1580 2023-02-15 20:23:32 -05:00
Deluan
0ffdb2eee0 Bump minimum Go version to 1.19 2023-02-15 20:20:08 -05:00
987 changed files with 63147 additions and 49555 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.19",
"VARIANT": "1.24",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v16"
"NODE_VERSION": "v20"
}
},
"workspaceMount": "",
@@ -18,33 +18,37 @@
"--volume=${localWorkspaceFolder}:/workspaces/${localWorkspaceFolderBasename}:Z"
],
// Set *default* container specific settings.json values on container create.
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"go.useGoProxyToCheckForToolUpdates": false,
"go.useLanguageServer": true,
"go.gopath": "/go",
"go.goroot": "/usr/local/go",
"go.toolsGopath": "/go/bin",
"go.formatTool": "goimports",
"go.lintOnSave": "package",
"go.lintTool": "golangci-lint",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"go.useGoProxyToCheckForToolUpdates": false,
"go.useLanguageServer": true,
"go.gopath": "/go",
"go.goroot": "/usr/local/go",
"go.toolsGopath": "/go/bin",
"go.formatTool": "goimports",
"go.lintOnSave": "package",
"go.lintTool": "golangci-lint",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go",
"esbenp.prettier-vscode",
"tamasfe.even-better-toml"
]
}
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go",
"esbenp.prettier-vscode",
"tamasfe.even-better-toml"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
4533,

View File

@@ -1,10 +1,18 @@
.DS_Store
ui/node_modules
ui/build
!ui/build/.gitkeep
Dockerfile
docker-compose*.yml
data
*.db
testDB
navidrome
navidrome.db
navidrome.toml
tmp
!tmp/taglib
dist
binaries
cache
music
!Dockerfile

View File

@@ -1,37 +0,0 @@
---
name: Bug Report
about: Use this template for submitting a bug report.
title: ""
labels: bug
assignees: ""
---
<!-- Please check that another issue for the same bug has not been already made by searching the [issues](https://github.com/navidrome/navidrome/issues) -->
### Description
A clear and concise description of what the bug is.
### Expected Behaviour
What you would have expected to happen instead.
### Steps to reproduce
1. Open the '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
### Platform information
- Navidrome version: <!-- e.g. v0.40.0 -->
- Browser and version: <!-- e.g. Firefox v87.0b9 -->
- Operating System: <!-- e.g. Ubuntu 20.04 and whether using a binary, docker or built from source -->
### Additional information
Any other information that may be relevant or give context to the problem.
- Screenshots (if applicable)?
- Logs? <!-- Turn the log level up to trace -->
- Client used? <!-- e.g. DSub v5.5.2R2 -->

103
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,103 @@
name: Bug Report
description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered.
title: "[Bug]: "
labels: ["bug", "triage"]
#assignees:
# - deluan
body:
- type: markdown
attributes:
value: |
### Thanks for taking the time to fill out this bug report!
- type: checkboxes
id: requirements
attributes:
label: "I confirm that:"
options:
- label: I have searched the existing [open AND closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) to see if an issue already exists for the bug I've encountered
required: true
- label: I'm using the latest version (your issue may have been fixed already)
required: false
- type: input
id: version
attributes:
label: Version
description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already).
validations:
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this scenario...
2. With this config...
3. Click (or Execute) '...'
4. See error...
validations:
required: false
- type: textarea
id: env
attributes:
label: Environment
description: |
examples:
- **OS**: Ubuntu 20.04
- **Browser**: Chrome 110.0.5481.177 on Windows 11
- **Client**: DSub 5.5.1
value: |
- OS:
- Browser:
- Client:
render: markdown
- type: dropdown
id: distribution
attributes:
label: How Navidrome is installed?
multiple: false
options:
- Docker
- Binary (from downloads page)
- Package
- Built from sources
validations:
required: true
- type: textarea
id: config
attributes:
label: Configuration
description: Please copy and paste your `navidrome.toml` (and/or `docker-compose.yml`) configuration. This will be automatically formatted into code, so no need for backticks.
render: toml
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output (change your `LogLevel` (`ND_LOGLEVEL`) to debug). This will be automatically formatted into code, so no need for backticks. ([Where I can find the logs?](https://www.navidrome.org/docs/faq/#where-are-the-logs))
render: shell
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach screenshots by clicking this area to highlight it and then dragging files in.
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
options:
- label: I agree to follow Navidrome's Code of Conduct
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Ideas for new features
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
about: This is the place to share and discuss new ideas and potentially new features.
- name: Support requests
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
about: This is the place to ask questions.

View File

@@ -1,24 +0,0 @@
---
name: Feature Request
about: Use this template to request for a feature.
title: ""
labels: enhancement
assignees: ""
---
<!-- Please check that another issue for the same feature request has not been already made by searching the [issues](https://github.com/navidrome/navidrome/issues) -->
### Is your feature request related to a problem? Please describe.
A clear and concise description of what the problem is. For e.g. I'm always frustrated when '...'
### Describe the solution you'd like
A clear and concise description of what you would like to happen.
### Describe alternative solutions that would also satisfy this problem
A clear and concise description of any alternative solutions or features you've considered.
### Additional context
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,23 @@
name: 'Download TagLib'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
version:
description: 'Version of TagLib to download'
required: true
platform:
description: 'Platform to download TagLib for'
default: 'linux-amd64'
runs:
using: 'composite'
steps:
- name: Download TagLib
shell: bash
run: |
mkdir -p /tmp/taglib
cd /tmp
FILE=taglib-${{ inputs.platform }}.tar.gz
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
tar -xzf ${FILE} -C taglib
PKG_CONFIG_PREFIX=/tmp/taglib
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV

View File

@@ -0,0 +1,84 @@
name: 'Prepare Docker Buildx environment'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
github_token:
description: 'GitHub token'
required: true
default: ''
hub_repository:
description: 'Docker Hub repository to push images to'
required: false
default: ''
hub_username:
description: 'Docker Hub username'
required: false
default: ''
hub_password:
description: 'Docker Hub password'
required: false
default: ''
outputs:
tags:
description: 'Docker image tags'
value: ${{ steps.meta.outputs.tags }}
labels:
description: 'Docker image labels'
value: ${{ steps.meta.outputs.labels }}
annotations:
description: 'Docker image annotations'
value: ${{ steps.meta.outputs.annotations }}
version:
description: 'Docker image version'
value: ${{ steps.meta.outputs.version }}
hub_repository:
description: 'Docker Hub repository'
value: ${{ env.DOCKER_HUB_REPO }}
hub_enabled:
description: 'Is Docker Hub enabled'
value: ${{ env.DOCKER_HUB_ENABLED }}
runs:
using: 'composite'
steps:
- name: Check Docker Hub configuration
shell: bash
run: |
if [ -z "${{inputs.hub_repository}}" ]; then
echo "DOCKER_HUB_REPO=none" >> $GITHUB_ENV
echo "DOCKER_HUB_ENABLED=false" >> $GITHUB_ENV
else
echo "DOCKER_HUB_REPO=${{inputs.hub_repository}}" >> $GITHUB_ENV
echo "DOCKER_HUB_ENABLED=true" >> $GITHUB_ENV
fi
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v3
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.github_token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v5
with:
labels: |
maintainer=deluan@navidrome.org
images: |
name=${{env.DOCKER_HUB_REPO}},enable=${{env.DOCKER_HUB_ENABLED}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=raw,value=develop,enable={{is_default_branch}}

View File

@@ -10,3 +10,13 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/.github/workflows"
schedule:
interval: weekly
open-pull-requests-limit: 10

View File

@@ -1,22 +0,0 @@
#!/bin/bash
GIT_TAG="${GITHUB_REF##refs/tags/}"
GIT_BRANCH="${GITHUB_REF##refs/heads/}"
GIT_SHA=$(git rev-parse --short HEAD)
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
DOCKER_IMAGE_TAG="--tag ${DOCKER_IMAGE}:sha-${GIT_SHA}"
if [[ $PR_NUM != "null" ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:pr-${PR_NUM}"
fi
if [[ $GITHUB_REF != "$GIT_TAG" ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:${GIT_TAG#v} --tag ${DOCKER_IMAGE}:latest"
elif [[ $GITHUB_REF == "refs/heads/master" ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:develop"
elif [[ $GIT_BRANCH = feature/* ]]; then
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG} --tag ${DOCKER_IMAGE}:$(echo $GIT_BRANCH | tr / -)"
fi
echo ${DOCKER_IMAGE_TAG}

View File

@@ -1,7 +1,7 @@
name: Add download link to PR
on:
workflow_run:
workflows: ['Pipeline']
workflows: ['Pipeline: Test, Lint, Build']
types: [completed]
jobs:
pr_comment:

View File

@@ -1,38 +0,0 @@
#####################################################
### Copy platform specific binary
FROM bash as copy-binary
ARG TARGETPLATFORM
RUN echo "Target Platform = ${TARGETPLATFORM}"
COPY dist .
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64_v1/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/386" ]; then cp navidrome_linux_386_linux_386/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
RUN chmod +x /navidrome
#####################################################
### Build Final Image
FROM alpine as release
LABEL maintainer="deluan@navidrome.org"
# Install ffmpeg and output build config
RUN apk add --no-cache ffmpeg
RUN ffmpeg -buildconf
COPY --from=copy-binary /navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER /music
ENV ND_DATAFOLDER /data
ENV ND_PORT 4533
ENV GODEBUG "asyncpreemptoff=1"
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

View File

@@ -1,4 +1,4 @@
name: Pipeline
name: "Pipeline: Test, Lint, Build"
on:
push:
branches:
@@ -8,81 +8,118 @@ on:
pull_request:
branches:
- master
concurrency:
group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }}
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.0.2-1"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
git-version:
name: Get version info
runs-on: ubuntu-latest
outputs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Show git version info
run: |
echo "git describe (dirty): $(git describe --dirty --always --tags)"
echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)"
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
echo "github_ref: $GITHUB_REF"
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
git tag -l
- name: Determine git current SHA and latest tag
id: git-version
run: |
GIT_TAG=$(git tag --sort=-committerdate | head -n 1)
if [ -n "$GIT_TAG" ]; then
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
GIT_TAG=${GIT_TAG}-SNAPSHOT
fi
echo "GIT_TAG=$GIT_TAG" >> $GITHUB_OUTPUT
fi
GIT_SHA=$(git rev-parse --short HEAD)
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
if [[ $PR_NUM != "null" ]]; then
GIT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-8)
GIT_SHA="pr-${PR_NUM}/${GIT_SHA}"
fi
echo "GIT_SHA=$GIT_SHA" >> $GITHUB_OUTPUT
echo "GIT_TAG=$GIT_TAG"
echo "GIT_SHA=$GIT_SHA"
go-lint:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- name: Install taglib
run: sudo apt-get install libtag1-dev
- uses: actions/checkout@v4
- name: Set up Go 1.19
uses: actions/setup-go@v3
- name: Download TagLib
uses: ./.github/actions/download-taglib
with:
go-version: 1.19
- uses: actions/checkout@v3
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v7
with:
version: latest
github-token: ${{ secrets.GITHUB_TOKEN }}
problem-matchers: true
args: --timeout 2m
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
- name: Run go goimports
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$'`
- run: go mod tidy
- name: Verify no changes from goimports and go mod tidy
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
echo "To fix this check, run: goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"
echo 'To fix this check, run "make format" and commit the changes'
exit 1
fi
go:
name: Test with Go ${{ matrix.go_version }}
name: Test Go code
runs-on: ubuntu-latest
strategy:
matrix:
go_version: [1.20.x,1.19.x]
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
- name: Download TagLib
uses: ./.github/actions/download-taglib
with:
go-version: ${{ matrix.go_version }}
cache: true
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: Download dependencies
if: steps.cache-go.outputs.cache-hit != 'true'
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
run: go mod download
- name: Test
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
run: go test -race -cover ./... -v
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
go test -shuffle=on -tags netgo -race -cover ./... -v
js:
name: Build JS bundle
name: Test JS code
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: 16
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,90 +141,289 @@ jobs:
cd ui
npm run build
- uses: actions/upload-artifact@v3
with:
name: js-bundle
path: ui/build
retention-days: 7
binaries:
name: Build binaries
needs: [js, go, go-lint]
i18n-lint:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/checkout@v4
- run: |
set -e
for file in resources/i18n/*.json; do
echo "Validating $file"
if ! jq empty "$file" 2>error.log; then
error_message=$(cat error.log)
line_number=$(echo "$error_message" | grep -oP 'line \K[0-9]+')
echo "::error file=$file,line=$line_number::$error_message"
exit 1
fi
done
- uses: actions/download-artifact@v3
with:
name: js-bundle
path: ui/build
check-push-enabled:
name: Check Docker configuration
runs-on: ubuntu-latest
outputs:
is_enabled: ${{ steps.check.outputs.is_enabled }}
steps:
- name: Check if Docker push is configured
id: check
run: echo "is_enabled=${{ secrets.DOCKER_HUB_USERNAME != '' }}" >> $GITHUB_OUTPUT
- name: Config /github/workspace folder as trusted
uses: docker://deluan/ci-goreleaser:1.19.5-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.19.5-1
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.19.5-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist
- uses: actions/upload-artifact@v3
with:
name: binaries
path: |
dist
!dist/*.tar.gz
!dist/*.zip
retention-days: 7
docker:
name: Build Docker images
needs: [binaries]
build:
name: Build
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
runs-on: ubuntu-latest
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
IS_ARMV5: ${{ matrix.platform == 'linux/arm/v5' && 'true' || 'false' }}
IS_DOCKER_PUSH_CONFIGURED: ${{ needs.check-push-enabled.outputs.is_enabled == 'true' }}
DOCKER_BUILD_SUMMARY: false
GIT_SHA: ${{ needs.git-version.outputs.git_sha }}
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
steps:
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
if: env.DOCKER_IMAGE != ''
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
if: env.DOCKER_IMAGE != ''
- uses: actions/checkout@v3
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v3
if: env.DOCKER_IMAGE != ''
with:
name: binaries
path: dist
- name: Build the Docker image and push
if: env.DOCKER_IMAGE != ''
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
DOCKER_PLATFORM: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64
- name: Sanitize platform name
id: set-platform
run: |
echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin
docker buildx build --platform ${DOCKER_PLATFORM} `.github/workflows/docker-tags.sh` -f .github/workflows/pipeline.dockerfile --push .
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v4
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
outputs: |
type=local,dest=./output/${{ env.PLATFORM }}
target: binary
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v4
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
retention-days: 7
- name: Build and push image by digest
id: push-image
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker.outputs.labels }}
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
outputs: |
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push-image.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
push-manifest:
name: Push Docker manifest
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Create manifest list and push to Docker Hub
working-directory: /tmp/digests
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
- name: Inspect image in Docker Hub
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
done
msi:
name: Build Windows installers
needs: [build, git-version]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: ./binaries
pattern: navidrome-windows*
merge-multiple: true
- name: Install Wix
run: sudo apt-get install -y wixl jq
- name: Build MSI
env:
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
run: |
rm -rf binaries/msi
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} 386
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} amd64
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v4
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
retention-days: 7
release:
name: Package/Release
needs: [build, msi]
runs-on: ubuntu-latest
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v4
with:
path: ./binaries
pattern: navidrome-*
merge-multiple: true
- run: ls -lR ./binaries
- name: Set RELEASE_FLAGS for snapshot releases
if: env.IS_RELEASE == 'false'
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Remove build artifacts
run: |
ls -l ./dist
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
with:
name: packages
path: dist/navidrome_0*
- id: set-package-list
name: Export list of generated packages
run: |
cd dist
set +x
ITEMS=$(ls navidrome_0* | sed 's/^navidrome_0[^_]*_linux_//' | jq -R -s -c 'split("\n")[:-1]')
echo $ITEMS
echo "package_list=${ITEMS}" >> $GITHUB_OUTPUT
upload-packages:
name: Upload Linux PKG
runs-on: ubuntu-latest
needs: [release]
strategy:
matrix:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v4
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
# delete-artifacts:
# name: Delete unused artifacts
# runs-on: ubuntu-latest
# needs: [upload-packages]
# steps:
# - name: Delete all-packages artifact
# env:
# GH_TOKEN: ${{ github.token }}
# run: |
# for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("packages")) | .id'); do
# gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
# done

56
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: 'Close stale issues and PRs'
on:
workflow_dispatch:
schedule:
- cron: '30 1 * * *'
permissions:
contents: read
jobs:
stale:
permissions:
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
with:
process-only: 'issues, prs'
issue-inactive-days: 120
pr-inactive-days: 120
log-output: true
add-issue-labels: 'frozen-due-to-age'
add-pr-labels: 'frozen-due-to-age'
issue-comment: >
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@v9
with:
operations-per-run: 999
days-before-issue-stale: 180
days-before-pr-stale: 180
days-before-issue-close: 30
days-before-pr-close: 30
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
recent activity. The resources of the Navidrome team are limited, and so we are asking for your help.
If this is a **bug** and you can still reproduce this error on the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.
If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.
This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
stale-pr-message: This PR has been automatically marked as stale because it has not had
recent activity. The resources of the Navidrome team are limited, and so we are asking for your help.
Please check https://github.com/navidrome/navidrome/blob/master/CONTRIBUTING.md#pull-requests and verify that this code contribution fits with the description. If yes, tell it in a comment.
This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions.
stale-issue-label: 'stale'
exempt-issue-labels: 'keep,security'
stale-pr-label: 'stale'
exempt-pr-labels: 'keep,security'

93
.github/workflows/update-translations.sh vendored Executable file
View File

@@ -0,0 +1,93 @@
#!/bin/sh
set -e
I18N_DIR=resources/i18n
# Function to process JSON: remove empty attributes and sort
process_json() {
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
}
# Function to check differences between local and remote translations
check_lang_diff() {
filename=${I18N_DIR}/"$1".json
url=$(curl -s -X POST https://poeditor.com/api/ \
-d api_token="${POEDITOR_APIKEY}" \
-d action="export" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$1" \
-d type="key_value_json" | jq -r .item)
if [ -z "$url" ]; then
echo "Failed to export $1"
return 1
fi
curl -sSL "$url" > poeditor.json
process_json "$filename" > "$filename".tmp
process_json poeditor.json > poeditor.tmp
diff=$(diff -u "$filename".tmp poeditor.tmp) || true
if [ -n "$diff" ]; then
echo "$diff"
mv poeditor.json "$filename"
fi
rm -f poeditor.json poeditor.tmp "$filename".tmp
}
# Function to get the list of languages
get_language_list() {
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/list \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}")
echo $response
}
# Function to get the language name from the language code
get_language_name() {
lang_code="$1"
lang_list="$2"
lang_name=$(echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name")
if [ -z "$lang_name" ]; then
echo "Error: Language code '$lang_code' not found" >&2
return 1
fi
echo "$lang_name"
}
# Function to get the language code from the file path
get_lang_code() {
filepath="$1"
# Extract just the filename
filename=$(basename "$filepath")
# Remove the extension
lang_code="${filename%.*}"
echo "$lang_code"
}
lang_list=$(get_language_list)
# Check differences for each language
for file in ${I18N_DIR}/*.json; do
code=$(get_lang_code "$file")
lang=$(jq -r .languageName < "$file")
lang_name=$(get_language_name "$code" "$lang_list")
echo "Downloading $lang_name - $lang ($code)"
check_lang_diff "$code"
done
# List changed languages to stderr
languages=""
for file in $(git diff --name-only --exit-code | grep json); do
lang_code=$(get_lang_code "$file")
lang_name=$(get_language_name "$lang_code" "$lang_list")
languages="${languages}$(echo "$lang_name" | tr -d '\n'), "
done
echo "${languages%??}" 1>&2

View File

@@ -1,4 +1,4 @@
name: Update Translations
name: POEditor import
on:
workflow_dispatch:
schedule:
@@ -6,18 +6,28 @@ on:
jobs:
update-translations:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Get updated translations
id: poeditor
env:
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
run: |
./update-translations.sh
.github/workflows/update-translations.sh 2> title.tmp
title=$(cat title.tmp)
echo "::set-output name=title::$title"
rm title.tmp
- name: Show changes, if any
run: |
git status --porcelain
git diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@v4
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PAT }}
commit-message: Update translations
title: Update translations from POEditor
author: "navidrome-bot <navidrome-bot@navidrome.org>"
commit-message: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor"
title: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor"
branch: update-translations

14
.gitignore vendored
View File

@@ -11,15 +11,17 @@ wiki
TODO.md
var
navidrome.toml
!release/linux/navidrome.toml
master.zip
testDB
navidrome.db
cache/*
*.swp
embedded_gen.go
dist
music
docker-compose.yml
navidrome.db-shm
navidrome.db-wal
tags
*.db*
.gitinfo
docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-master
*.exe

View File

@@ -1,36 +1,58 @@
version: "2"
run:
go: "1.19"
build-tags:
- netgo
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- depguard
- copyloopvar
- dogsled
- durationcheck
- errcheck
- errorlint
- exportloopref
- gocritic
- gocyclo
- goprintffuncname
- gosec
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- nilerr
- rowserrcheck
- staticcheck
- typecheck
- unconvert
- unused
- whitespace
issues:
exclude-rules:
- linters:
- gosec
text: "(G501|G401|G505):"
disable:
- staticcheck
settings:
gocritic:
disable-all: true
enabled-checks:
- deprecatedComment
gosec:
excludes:
- G501
- G401
- G505
- G115
govet:
enable:
- nilness
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1,139 +0,0 @@
# GoReleaser config
project_name: navidrome
builds:
- id: navidrome_linux_amd64
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static -lz'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_386
env:
- CGO_ENABLED=1
- PKG_CONFIG_PATH=/i386/lib/pkgconfig
goos:
- linux
goarch:
- "386"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm
env:
- CGO_ENABLED=1
- CC=arm-linux-gnueabi-gcc
- CXX=arm-linux-gnueabi-g++
- PKG_CONFIG_PATH=/arm/lib/pkgconfig
goos:
- linux
goarch:
- arm
goarm:
- "5"
- "6"
- "7"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
- PKG_CONFIG_PATH=/arm64/lib/pkgconfig
goos:
- linux
goarch:
- arm64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_386
env:
- CGO_ENABLED=1
- CC=i686-w64-mingw32-gcc
- CXX=i686-w64-mingw32-g++
- PKG_CONFIG_PATH=/mingw32/lib/pkgconfig
goos:
- windows
goarch:
- "386"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_amd64
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
- PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
goos:
- windows
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_darwin_amd64
env:
- CGO_ENABLED=1
- CC=o64-clang
- CXX=o64-clang++
- PKG_CONFIG_PATH=/darwin/lib/pkgconfig
goos:
- darwin
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
archives:
- format_overrides:
- goos: windows
format: zip
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: "{{ .ProjectName }}_checksums.txt"
snapshot:
name_template: "{{ .Tag }}-SNAPSHOT"
release:
draft: true
changelog:
# sort: asc
filters:
exclude:
- "^docs:"

2
.nvmrc
View File

@@ -1 +1 @@
v16
v20

View File

@@ -2,12 +2,15 @@
Navidrome is a streaming service which allows you to enjoy your music collection from anywhere. We'd welcome you to contribute to our open source project and make Navidrome even better. There are some basic guidelines which you need to follow if you like to contribute to Navidrome.
- [Asking Support Questions](#asking-support-questions)
- [Code of Conduct](#code-of-conduct)
- [Issues](#issues)
- [Questions](#questions)
- [Pull Requests](#pull-requests)
## Asking Support Questions
We have an active [discussion forum](https://github.com/navidrome/navidrome/discussions) where users and developers can ask questions. Please don't use the GitHub issue tracker to ask questions.
## Code of Conduct
Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
@@ -19,9 +22,6 @@ to the GitHub repository.
**Before opening a new issue, please check if the issue has not been already made by searching
the [issues](https://github.com/navidrome/navidrome/issues)**
## Questions
We would like to have discussions and general queries related to Navidrome on our [Discord channel](https://discord.gg/2qMuMyHfSV).
## Pull requests
Before submitting a pull request, ensure that you go through the following:
- Open a corresponding issue for the Pull Request, if not existing. The issue can be opened following [these guidelines](#issues)
@@ -48,14 +48,15 @@ This improves the readability of the messages
It can be one of the following:
1. **feat**: Addition of a new feature
2. **fix**: Bug fix
3. **docs**: Documentation Changes
4. **style**: Changes to styling
5. **refactor**: Refactoring of code
6. **perf**: Code that affects performance
7. **test**: Updating or improving the current tests
8. **build**: Changes to Build process
9. **revert**: Reverting to a previous commit
10. **chore** : updating grunt tasks etc
3. **sec**: Fixing security issues
4. **docs**: Documentation Changes
5. **style**: Changes to styling
6. **refactor**: Refactoring of code
7. **perf**: Code that affects performance
8. **test**: Updating or improving the current tests
9. **build**: Changes to Build process
10. **revert**: Reverting to a previous commit
11. **chore** : updating grunt tasks etc
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section

145
Dockerfile Normal file
View File

@@ -0,0 +1,145 @@
FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross
########################################################################################################################
### Build xx (orignal image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS xx-build
# v1.5.0
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
cd xx && \
git checkout ${XX_VERSION} && \
mkdir -p /out && \
cp src/xx-* /out/
RUN cd /out && \
ln -s xx-cc /out/xx-clang && \
ln -s xx-cc /out/xx-clang++ && \
ln -s xx-cc /out/xx-c++ && \
ln -s xx-apt /out/xx-apt-get
# xx mimics the original tonistiigi/xx image
FROM scratch AS xx
COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.0.2-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
RUN <<EOT
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
FILE=taglib-${PLATFORM}.tar.gz
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
wget ${DOWNLOAD_URL}
mkdir /taglib
tar -xzf ${FILE} -C /taglib
EOT
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
WORKDIR /app
# Install node dependencies
COPY ui/package.json ui/package-lock.json ./
COPY ui/bin/ ./bin/
RUN npm ci
# Build bundle
COPY ui/ ./
RUN npm run build -- --outDir=/build
FROM scratch AS ui-bundle
COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.24-bookworm AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
FROM --platform=$BUILDPLATFORM base AS build
# Install build dependencies for the target platform
ARG TARGETPLATFORM
RUN xx-apt install -y binutils gcc g++ libc6-dev zlib1g-dev
RUN xx-verify --setup
RUN --mount=type=bind,source=. \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
go mod download
ARG GIT_SHA
ARG GIT_TAG
RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
cat $(go env GOENV)
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
# So let's use gcc for everything except Darwin.
if [ "$(xx-info os)" != "darwin" ]; then
export CC=$(xx-info)-gcc
export CXX=$(xx-info)-g++
export LD_EXTRA="-extldflags '-static -latomic'"
fi
if [ "$(xx-info os)" = "windows" ]; then
export EXT=".exe"
fi
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome${EXT} .
EOT
# Verify if the binary was built for the correct platform and it is statically linked
RUN xx-verify --static /out/navidrome*
FROM scratch AS binary
COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.21 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# Install ffmpeg and mpv
RUN apk add -U --no-cache ffmpeg mpv sqlite
# Copy navidrome binary
COPY --from=build /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
ENV GODEBUG="asyncpreemptoff=1"
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

154
Makefile
View File

@@ -3,13 +3,21 @@ NODE_VERSION=$(shell cat .nvmrc)
ifneq ("$(wildcard .git/HEAD)","")
GIT_SHA=$(shell git rev-parse --short HEAD)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
else
GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
endif
CI_RELEASER_VERSION=1.19.5-1 ## https://github.com/navidrome/ci-goreleaser
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.0.2-1
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@@ -17,44 +25,60 @@ setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and
.PHONY: setup
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
npx foreman -j Procfile.dev -p 4533 start
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env ##@Development Start the backend in development mode
@go run github.com/cespare/reflex -d none -c reflex.conf
server: check_go_env buildjs ##@Development Start the backend in development mode
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
.PHONY: server
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go run github.com/onsi/ginkgo/v2/ginkgo watch -notify ./...
go tool ginkgo watch -tags=netgo -notify ./...
.PHONY: watch
test: ##@Development Run Go tests
go test -race ./...
go test -tags netgo ./...
.PHONY: test
testall: test ##@Development Run Go and JS tests
@(cd ./ui && npm test -- --watchAll=false)
testrace: ##@Development Run Go tests with race detector
go test -tags netgo -race -shuffle=on ./...
.PHONY: test
testall: testrace ##@Development Run Go and JS tests
@(cd ./ui && npm run test:ci)
.PHONY: testall
lint: ##@Development Lint Go code
go run github.com/golangci/golangci-lint/cmd/golangci-lint run -v --timeout 5m
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@(cd ./ui && npm run check-formatting && npm run lint)
@(cd ./ui && npm run check-formatting) || (echo "\n\nPlease run 'npm run prettier' to fix formatting issues." && exit 1)
@(cd ./ui && npm run lint)
.PHONY: lintall
format: ##@Development Format code
@(cd ./ui && npm run prettier)
@go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$`
@go mod tidy
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go run github.com/google/wire/cmd/wire ./...
go tool wire gen -tags=netgo ./...
.PHONY: wire
snapshots: ##@Development Update (GoLang) Snapshot tests
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo ./server/subsonic/...
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
.PHONY: snapshots
migration: ##@Development Create an empty migration file
@if [ -z "${name}" ]; then echo "Usage: make migration name=name_of_migration_file"; exit 1; fi
go run github.com/pressly/goose/cmd/goose -dir db/migration create ${name}
migration-sql: ##@Development Create an empty SQL migration file
@if [ -z "${name}" ]; then echo "Usage: make migration-sql name=name_of_migration_file"; exit 1; fi
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name} sql
.PHONY: migration
migration-go: ##@Development Create an empty Go migration file
@if [ -z "${name}" ]; then echo "Usage: make migration-go name=name_of_migration_file"; exit 1; fi
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name}
.PHONY: migration
setup-dev: setup
@@ -66,45 +90,76 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
@(cd .git/hooks && ln -sf ../../git/* .)
.PHONY: setup-git
buildall: buildjs build ##@Build Build the project, both frontend and backend
.PHONY: buildall
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
build: check_go_env buildjs ##@Build Build the project
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: build
buildjs: check_node_env ##@Build Build only frontend
@(cd ./ui && npm run build)
buildall: deprecated build
.PHONY: buildall
debug-build: check_go_env buildjs ##@Build Build the project (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)" -tags=netgo
.PHONY: debug-build
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
.PHONY: buildjs
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser release --rm-dist --skip-publish --snapshot
.PHONY: all
docker-buildjs: ##@Build Build only frontend using Docker
docker build --output "./ui" --target ui-bundle .
.PHONY: docker-buildjs
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
echo "Options:"; \
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
exit 1; \
fi
@echo "Building binaries for ${GOOS}/${GOARCH}"
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}
.PHONY: single
ui/build/index.html: $(UI_SRC_FILES)
@(cd ./ui && npm run build)
warning-noui-build:
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
.PHONY: warning-noui-build
docker-platforms: ##@Cross_Compilation List supported platforms
@echo "Supported platforms:"
@echo "$(SUPPORTED_PLATFORMS)" | tr ',' '\n' | sort | sed 's/^/ /'
@echo "\nUsage: make PLATFORMS=\"linux/amd64\" docker-build"
@echo " make IMAGE_PLATFORMS=\"linux/amd64\" docker-image"
.PHONY: docker-platforms
docker-build: ##@Cross_Compilation Cross-compile for any supported platform (check `make docker-platforms`)
docker buildx build \
--platform $(PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--output "./binaries" --target binary .
.PHONY: docker-build
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
docker buildx build \
--platform $(IMAGE_PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--tag $(DOCKER_TAG) .
.PHONY: docker-image
docker-msi: ##@Cross_Compilation Build MSI installer for Windows
make docker-build PLATFORMS=windows/386,windows/amd64
DOCKER_CLI_HINTS=false docker build -q -t navidrome-msi-builder -f release/wix/msitools.dockerfile .
@rm -rf binaries/msi
docker run -it --rm -v $(PWD):/workspace -v $(PWD)/binaries:/workspace/binaries -e GIT_TAG=${GIT_TAG} \
navidrome-msi-builder sh -c "release/wix/build_msi.sh /workspace 386 && release/wix/build_msi.sh /workspace amd64"
@du -h binaries/msi/*.msi
.PHONY: docker-msi
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot
.PHONY: package
get-music: ##@Development Download some free music from Navidrome's demo instance
mkdir -p music
( cd music; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
for file in *.zip; do unzip -n $${file}; done )
@echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music
@@ -113,6 +168,11 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
##########################################
#### Miscellaneous
clean:
@rm -rf ./binaries ./dist ./ui/build/*
@touch ./ui/build/.gitkeep
.PHONY: clean
release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
go mod tidy
@@ -152,6 +212,10 @@ check_node_env:
pre-push: lintall testall
.PHONY: pre-push
deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated
.DEFAULT_GOAL := help
HELP_FUN = \

View File

@@ -1,2 +1,2 @@
JS: sh -c "cd ./ui && npm start"
GO: go run github.com/cespare/reflex -d none -c reflex.conf
GO: go tool reflex -d none -c reflex.conf

View File

@@ -1,6 +1,6 @@
<a href="https://www.navidrome.org"><img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" /></a>
# Navidrome Music Server
# Navidrome Music Server &nbsp;[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Tired%20of%20paying%20for%20music%20subscriptions%2C%20and%20not%20finding%20what%20you%20really%20like%3F%20Roll%20your%20own%20streaming%20service%21&url=https://navidrome.org&via=navidrome)
[![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
@@ -9,6 +9,7 @@
[![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/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
[![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Navidrome%20Guru-006BFF?style=flat-square)](https://gurubase.io/g/navidrome)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
music collection from any browser or mobile device. It's like your personal Spotify!
@@ -30,11 +31,15 @@ please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or j
## Installation
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
If you plan to host Navidrome in the cloud, a great option is to get a virtual server at [BuyVM](https://my.frantech.ca/aff.php?aff=4605).
They have plans that start at $3.50/month! If you decide to sign up, please consider using our [affliliate link](https://my.frantech.ca/aff.php?aff=4605),
to help support the project <3
## Cloud Hosting
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
## Features
@@ -52,6 +57,15 @@ to help support the project <3
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
- Translated to **various languages**
## Translations
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
<a href="https://poeditor.com/">
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
</a>
## Documentation
All documentation can be found in the project's website: https://www.navidrome.org/docs.
Here are some useful direct links:

View File

@@ -0,0 +1,154 @@
package taglib
import (
"io/fs"
"os"
"time"
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type testFileInfo struct {
fs.FileInfo
}
func (t testFileInfo) BirthTime() time.Time {
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return t.FileInfo.ModTime()
}
var _ = Describe("Extractor", func() {
toP := func(name, sortName, mbid string) model.Participant {
return model.Participant{
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
}
}
roles := []struct {
model.Role
model.ParticipantList
}{
{model.RoleComposer, model.ParticipantList{
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
}},
{model.RoleLyricist, model.ParticipantList{
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
}},
{model.RoleArranger, model.ParticipantList{
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
}},
{model.RoleConductor, model.ParticipantList{
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
}},
{model.RoleDirector, model.ParticipantList{
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
}},
{model.RoleEngineer, model.ParticipantList{
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
}},
{model.RoleProducer, model.ParticipantList{
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
}},
{model.RoleRemixer, model.ParticipantList{
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
}},
{model.RoleDJMixer, model.ParticipantList{
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
}},
{model.RoleMixer, model.ParticipantList{
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
}},
}
var e *extractor
BeforeEach(func() {
e = &extractor{}
})
Describe("Participants", func() {
DescribeTable("test tags consistent across formats", func(format string) {
path := "tests/fixtures/test." + format
mds, err := e.Parse(path)
Expect(err).ToNot(HaveOccurred())
info := mds[path]
fileInfo, _ := os.Stat(path)
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
mf := metadata.ToMediaFile(1, "folderID")
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
Expect(actual).To(HaveLen(len(artists)))
for i := range artists {
actualArtist := actual[i]
expectedArtist := artists[i]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
}
}
if format != "m4a" {
performers := mf.Participants[model.RolePerformer]
Expect(performers).To(HaveLen(8))
rules := map[string][]string{
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
}
for name, rule := range rules {
mbid := rule[0]
for i := 1; i < len(rule); i++ {
found := false
for _, mapped := range performers {
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
found = true
break
}
}
Expect(found).To(BeTrue(), "Could not find matching artist")
}
}
}
},
Entry("FLAC format", "flac"),
Entry("M4a format", "m4a"),
Entry("OGG format", "ogg"),
Entry("WMA format", "wv"),
Entry("MP3 format", "mp3"),
Entry("WAV format", "wav"),
Entry("AIFF format", "aiff"),
)
})
})

151
adapters/taglib/taglib.go Normal file
View File

@@ -0,0 +1,151 @@
package taglib
import (
"io/fs"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
)
type extractor struct {
baseDir string
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
return Version()
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
fullPath := filepath.Join(e.baseDir, filePath)
tags, err := Read(fullPath)
if err != nil {
log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
return nil, err
}
// Parse audio properties
ap := metadata.AudioProperties{}
if length, ok := tags["_lengthinmilliseconds"]; ok && len(length) > 0 {
millis, _ := strconv.Atoi(length[0])
if millis > 0 {
ap.Duration = (time.Millisecond * time.Duration(millis)).Round(time.Millisecond * 10)
}
delete(tags, "_lengthinmilliseconds")
}
parseProp := func(prop string, target *int) {
if value, ok := tags[prop]; ok && len(value) > 0 {
*target, _ = strconv.Atoi(value[0])
delete(tags, prop)
}
}
parseProp("_bitrate", &ap.BitRate)
parseProp("_channels", &ap.Channels)
parseProp("_samplerate", &ap.SampleRate)
parseProp("_bitspersample", &ap.BitDepth)
// Parse track/disc totals
parseTuple := func(prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
parseTuple("track")
parseTuple("disc")
// Adjust some ID3 tags
parseLyrics(tags)
parseTIPL(tags)
delete(tags, "tmcl") // TMCL is already parsed by TagLib
return &metadata.Info{
Tags: tags,
AudioProperties: ap,
HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
}, nil
}
// parseLyrics make sure lyrics tags have language
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
// These are the only roles we support, based on Picard's tag map:
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
// and breaks it down into a map of roles and names, e.g.:
//
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for _, part := range strings.Split(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
// ignores fs, as taglib extractor only works with local files
return &extractor{baseDir}
})
}

View File

@@ -0,0 +1,296 @@
package taglib
import (
"io/fs"
"os"
"strings"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
e = &extractor{}
})
Describe("Parse", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := e.Parse(
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
// Test MP3
m := mds["tests/fixtures/test.mp3"]
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.HasPicture).To(BeTrue())
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
Expect(m.AudioProperties.BitRate).To(Equal(192))
Expect(m.AudioProperties.Channels).To(Equal(2))
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("tcmp", []string{"1"})),
)
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
Expect(m.Tags).ToNot(HaveKey("lyrics"))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English SYLT\n",
"[00:00.00]This is\n[00:02.50]English",
}), HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
"[00:00.00]This is\n[00:02.50]English SYLT\n",
})))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
"[00:00.00]This is\n[00:02.50]unspecified",
}), HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
})))
// Test OGG
m = mds["tests/fixtures/test.ogg"]
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeFalse())
})
DescribeTable("Format-Specific tests",
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[file]
Expect(m.HasPicture).To(BeFalse())
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
Expect(m.AudioProperties.Channels).To(Equal(channels))
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
))
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
HaveKeyWithValue("tracknumber", []string{"3"}),
HaveKeyWithValue("tracknumber", []string{"3/10"}),
))
if !strings.HasSuffix(file, "test.wma") {
// TODO Not sure why this is not working for WMA
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
}
Expect(m.Tags).To(Or(
HaveKeyWithValue("discnumber", []string{"1"}),
HaveKeyWithValue("discnumber", []string{"1/2"}),
))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
// WMA does not have a "compilation" tag, but "wm/iscompilation"
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
)
if id3Lyrics {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}))
} else {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]English",
}))
}
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false),
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false),
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true),
)
// Skip these tests when running as root
Context("Access Forbidden", func() {
var accessForbiddenFile string
var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0
if isRegularUser {
RegularUserContext = Context
}
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(f.Close()).To(Succeed())
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
})
})
It("correctly handle unreadable file due to insufficient read permission", func() {
_, err := e.extractMetadata(accessForbiddenFile)
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
files := []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
accessForbiddenFile,
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
})
})
})
})
Describe("Error Checking", func() {
It("returns a generic ErrPath if file does not exist", func() {
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
_, err := e.extractMetadata(testFilePath)
Expect(err).To(MatchError(fs.ErrNotExist))
})
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
// File has an empty TDAT frame
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
})
})
Describe("parseTIPL", func() {
var tags map[string][]string
BeforeEach(func() {
tags = make(map[string][]string)
})
Context("when the TIPL string is populated", func() {
It("correctly parses roles and names", func() {
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
})
It("handles multiple names for a single role", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
It("discards roles without names", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
parseTIPL(tags)
Expect(tags).ToNot(HaveKey("producer"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
})
Context("when the TIPL string is empty", func() {
It("does nothing", func() {
tags["tipl"] = []string{""}
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
Context("when the TIPL is not present", func() {
It("does nothing", func() {
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
})
})

View File

@@ -0,0 +1,242 @@
#include <stdlib.h>
#include <string.h>
#include <typeinfo>
#define TAGLIB_STATIC
#include <apeproperties.h>
#include <apetag.h>
#include <aifffile.h>
#include <asffile.h>
#include <dsffile.h>
#include <fileref.h>
#include <flacfile.h>
#include <id3v2tag.h>
#include <unsynchronizedlyricsframe.h>
#include <synchronizedlyricsframe.h>
#include <mp4file.h>
#include <mpegfile.h>
#include <opusfile.h>
#include <tpropertymap.h>
#include <vorbisfile.h>
#include <wavfile.h>
#include <wavfile.h>
#include <wavpackfile.h>
#include "taglib_wrapper.h"
char has_cover(const TagLib::FileRef f);
static char TAGLIB_VERSION[16];
char* taglib_version() {
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
return (char *)TAGLIB_VERSION;
}
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
if (f.isNull()) {
return TAGLIB_ERR_PARSE;
}
if (!f.audioProperties()) {
return TAGLIB_ERR_AUDIO_PROPS;
}
// Add audio properties to the tags
const TagLib::AudioProperties *props(f.audioProperties());
goPutInt(id, (char *)"_lengthinmilliseconds", props->lengthInMilliseconds());
goPutInt(id, (char *)"_bitrate", props->bitrate());
goPutInt(id, (char *)"_channels", props->channels());
goPutInt(id, (char *)"_samplerate", props->sampleRate());
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample());
if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample());
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample());
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample());
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample());
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample());
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample());
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
goPutInt(id, (char *)"_bitspersample", dsfProperties->bitsPerSample());
// Send all properties to the Go map
TagLib::PropertyMap tags = f.file()->properties();
TagLib::ID3v2::Tag *id3Tags = NULL;
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
if (mp3File != NULL) {
id3Tags = mp3File->ID3v2Tag();
}
if (id3Tags == NULL) {
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
id3Tags = wavFile->ID3v2Tag();
}
}
if (id3Tags == NULL) {
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
if (aiffFile && aiffFile->hasID3v2Tag()) {
id3Tags = aiffFile->tag();
}
}
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
// with many players, so they will not be parsed
if (id3Tags != NULL) {
const auto &frames = id3Tags->frameListMap();
for (const auto &kv: frames) {
if (kv.first == "USLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
tags.erase("LYRICS");
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
char *val = (char *)frame->text().toCString(true);
goPutLyrics(id, language, val);
}
} else if (kv.first == "SYLT") {
for (const auto &tag: kv.second) {
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
if (frame == NULL) continue;
const auto bv = frame->language();
char language[4] = {'x', 'x', 'x', '\0'};
if (bv.size() == 3) {
strncpy(language, bv.data(), 3);
}
const auto format = frame->timestampFormat();
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
for (const auto &line: frame->synchedText()) {
char *text = (char *)line.text.toCString(true);
goPutLyricLine(id, language, text, line.time);
}
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
const int sampleRate = props->sampleRate();
if (sampleRate != 0) {
for (const auto &line: frame->synchedText()) {
const int timeInMs = (line.time * 1000) / sampleRate;
char *text = (char *)line.text.toCString(true);
goPutLyricLine(id, language, text, timeInMs);
}
}
}
}
} else if (kv.first == "TIPL"){
if (!kv.second.isEmpty()) {
tags.insert(kv.first, kv.second.front()->toString());
}
}
}
}
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
if (m4afile != NULL) {
const auto itemListMap = m4afile->tag()->itemMap();
for (const auto item: itemListMap) {
char *key = (char *)item.first.toCString(true);
for (const auto value: item.second.toStringList()) {
char *val = (char *)value.toCString(true);
goPutM4AStr(id, key, val);
}
}
}
// WMA/ASF files may have additional tags not captured by the PropertyMap interface
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
if (asfFile != NULL) {
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
const auto itemListMap = asfTags->attributeListMap();
for (const auto item : itemListMap) {
tags.insert(item.first, item.second.front().toString());
}
}
// Send all collected tags to the Go map
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
++i) {
char *key = (char *)i->first.toCString(true);
for (TagLib::StringList::ConstIterator j = i->second.begin();
j != i->second.end(); ++j) {
char *val = (char *)(*j).toCString(true);
goPutStr(id, key, val);
}
}
// Cover art has to be handled separately
if (has_cover(f)) {
goPutStr(id, (char *)"has_picture", (char *)"true");
}
return 0;
}
// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
char has_cover(const TagLib::FileRef f) {
char hasCover = 0;
// ----- MP3
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
if (mp3File->ID3v2Tag()) {
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- FLAC
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
hasCover = !flacFile->pictureList().isEmpty();
}
// ----- MP4
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
hasCover = !coverArtList.isEmpty();
}
// ----- Ogg
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
}
// ----- Opus
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
hasCover = !opusFile->tag()->pictureList().isEmpty();
}
// ----- WMA
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
const TagLib::ASF::Tag *tag{asfFile->tag()};
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
}
// ----- WAV
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
if (wavFile->hasID3v2Tag()) {
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
return hasCover;
}

View File

@@ -0,0 +1,157 @@
package taglib
/*
#cgo !windows pkg-config: --define-prefix taglib
#cgo windows pkg-config: taglib
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
#cgo linux darwin CXXFLAGS: -std=c++11
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "taglib_wrapper.h"
*/
import "C"
import (
"encoding/json"
"fmt"
"os"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
"unsafe"
"github.com/navidrome/navidrome/log"
)
const iTunesKeyPrefix = "----:com.apple.itunes:"
func Version() string {
return C.GoString(C.taglib_version())
}
func Read(filename string) (tags map[string][]string, err error) {
// Do not crash on failures in the C code/library
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
err = fmt.Errorf("extractor: recovered from panic: %s", r)
}
}()
fp := getFilename(filename)
defer C.free(unsafe.Pointer(fp))
id, m, release := newMap()
defer release()
log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
res := C.taglib_read(fp, C.ulong(id))
switch res {
case C.TAGLIB_ERR_PARSE:
// Check additional case whether the file is unreadable due to permission
file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
defer file.Close()
if os.IsPermission(fileErr) {
return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
} else if fileErr != nil {
return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
} else {
return nil, fmt.Errorf("cannot parse file media file")
}
case C.TAGLIB_ERR_AUDIO_PROPS:
return nil, fmt.Errorf("can't get audio properties from file")
}
if log.IsGreaterOrEqualTo(log.LevelDebug) {
j, _ := json.Marshal(m)
log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
} else {
log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
}
return m, nil
}
type tagMap map[string][]string
var allMaps sync.Map
var mapsNextID atomic.Uint32
func newMap() (uint32, tagMap, func()) {
id := mapsNextID.Add(1)
m := tagMap{}
allMaps.Store(id, m)
return id, m, func() {
allMaps.Delete(id)
}
}
func doPutTag(id C.ulong, key string, val *C.char) {
if key == "" {
return
}
r, _ := allMaps.Load(uint32(id))
m := r.(tagMap)
k := strings.ToLower(key)
v := strings.TrimSpace(C.GoString(val))
m[k] = append(m[k], v)
}
//export goPutM4AStr
func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
k := C.GoString(key)
// Special for M4A, do not catch keys that have no actual name
k = strings.TrimPrefix(k, iTunesKeyPrefix)
doPutTag(id, k, val)
}
//export goPutStr
func goPutStr(id C.ulong, key *C.char, val *C.char) {
doPutTag(id, C.GoString(key), val)
}
//export goPutInt
func goPutInt(id C.ulong, key *C.char, val C.int) {
valStr := strconv.Itoa(int(val))
vp := C.CString(valStr)
defer C.free(unsafe.Pointer(vp))
goPutStr(id, key, vp)
}
//export goPutLyrics
func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
doPutTag(id, "lyrics:"+C.GoString(lang), val)
}
//export goPutLyricLine
func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
language := C.GoString(lang)
line := C.GoString(text)
timeGo := int64(time)
ms := timeGo % 1000
timeGo /= 1000
sec := timeGo % 60
timeGo /= 60
minimum := timeGo % 60
formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
key := "lyrics:" + language
r, _ := allMaps.Load(uint32(id))
m := r.(tagMap)
k := strings.ToLower(key)
existing, ok := m[k]
if ok {
existing[0] += formattedLine
} else {
m[k] = []string{formattedLine}
}
}

View File

@@ -0,0 +1,24 @@
#define TAGLIB_ERR_PARSE -1
#define TAGLIB_ERR_AUDIO_PROPS -2
#ifdef __cplusplus
extern "C" {
#endif
#ifdef WIN32
#define FILENAME_CHAR_T wchar_t
#else
#define FILENAME_CHAR_T char
#endif
extern void goPutM4AStr(unsigned long id, char *key, char *val);
extern void goPutStr(unsigned long id, char *key, char *val);
extern void goPutInt(unsigned long id, char *key, int val);
extern void goPutLyrics(unsigned long id, char *lang, char *val);
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
char* taglib_version();
#ifdef __cplusplus
}
#endif

186
cmd/backup.go Normal file
View File

@@ -0,0 +1,186 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var (
backupCount int
backupDir string
force bool
restorePath string
)
func init() {
rootCmd.AddCommand(backupRoot)
backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup")
backupRoot.AddCommand(backupCmd)
pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups")
pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration")
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
backupRoot.AddCommand(pruneCmd)
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
}
var (
backupRoot = &cobra.Command{
Use: "backup",
Aliases: []string{"bkp"},
Short: "Create, restore and prune database backups",
Long: "Create, restore and prune database backups",
}
backupCmd = &cobra.Command{
Use: "create",
Short: "Create a backup database",
Long: "Manually backup Navidrome database. This will ignore BackupCount",
Run: func(cmd *cobra.Command, _ []string) {
runBackup(cmd.Context())
},
}
pruneCmd = &cobra.Command{
Use: "prune",
Short: "Prune database backups",
Long: "Manually prune database backups according to backup rules",
Run: func(cmd *cobra.Command, _ []string) {
runPrune(cmd.Context())
},
}
restoreCommand = &cobra.Command{
Use: "restore",
Short: "Restore Navidrome database",
Long: "Restore Navidrome database from a backup. This must be done offline",
Run: func(cmd *cobra.Command, _ []string) {
runRestore(cmd.Context())
},
}
)
func runBackup(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Backup complete", "elapsed", elapsed, "path", path)
}
func runPrune(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
if backupCount != -1 {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force {
fmt.Println("Warning: pruning ALL backups")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Prune cancelled")
return
}
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
}
func runRestore(ctx context.Context) {
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
if !force {
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
return
}
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Restore complete", "elapsed", elapsed)
}

79
cmd/inspect.go Normal file
View File

@@ -0,0 +1,79 @@
package cmd
import (
"encoding/json"
"fmt"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
var (
format string
)
func init() {
inspectCmd.Flags().StringVarP(&format, "format", "f", "jsonindent", "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.([]core.InspectOutput)
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
}
func runInspector(args []string) {
marshal := marshalers[format]
if marshal == nil {
log.Fatal("Invalid format", "format", format)
}
var out []core.InspectOutput
for _, filePath := range args {
if !model.IsAudioFile(filePath) {
log.Warn("Not an audio file", "file", filePath)
continue
}
output, err := core.Inspect(filePath, 1, "")
if err != nil {
log.Warn("Unable to process file", "file", filePath, "error", err)
continue
}
out = append(out, *output)
}
data, _ := marshal(out)
fmt.Println(string(data))
}

View File

@@ -2,8 +2,12 @@ package cmd
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/auth"
@@ -15,31 +19,57 @@ import (
)
var (
playlistID string
outputFile string
playlistID string
outputFile string
userID string
outputFormat string
)
type displayPlaylist struct {
Id string `json:"id"`
Name string `json:"name"`
OwnerName string `json:"ownerName"`
OwnerId string `json:"ownerId"`
Public bool `json:"public"`
}
type displayPlaylists []displayPlaylist
func init() {
plsCmd.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
plsCmd.Flags().StringVarP(&outputFile, "output", "o", "", "output file (default stdout)")
_ = plsCmd.MarkFlagRequired("playlist")
rootCmd.AddCommand(plsCmd)
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
plsCmd.AddCommand(listCommand)
}
var plsCmd = &cobra.Command{
Use: "pls",
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExporter()
},
}
var (
plsCmd = &cobra.Command{
Use: "pls",
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExporter()
},
}
listCommand = &cobra.Command{
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
runList()
},
}
)
func runExporter() {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
@@ -49,7 +79,7 @@ func runExporter() {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
if len(playlists) > 0 {
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true)
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
if err != nil {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
}
@@ -69,3 +99,58 @@ func runExporter() {
log.Fatal("Error writing to the output file", "file", outputFile, err)
}
}
func runList() {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
sqlDB := db.Db()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
user, err := ds.User(ctx).FindByUsername(userID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving user by name", "name", userID, err)
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(userID)
if err != nil {
log.Fatal("Error retrieving user by id", "id", userID, err)
}
}
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
playlists, err := ds.Playlist(ctx).GetAll(options)
if err != nil {
log.Fatal(ctx, "Failed to retrieve playlists", err)
}
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
for _, playlist := range playlists {
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
}
w.Flush()
} else {
display := make(displayPlaylists, len(playlists))
for idx, playlist := range playlists {
display[idx].Id = playlist.ID
display[idx].Name = playlist.Name
display[idx].OwnerId = playlist.OwnerID
display[idx].OwnerName = playlist.OwnerName
display[idx].Public = playlist.Public
}
j, _ := json.Marshal(display)
fmt.Printf("%s\n", j)
}
}

View File

@@ -2,29 +2,28 @@ package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5/middleware"
_ "github.com/navidrome/navidrome/adapters/taglib"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/server/backgrounds"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sync/errgroup"
)
var interrupted = errors.New("service was interrupted")
var (
cfgFile string
noBanner bool
@@ -38,17 +37,23 @@ Complete documentation is available at https://www.navidrome.org/docs`,
preRun()
},
Run: func(cmd *cobra.Command, args []string) {
runNavidrome()
runNavidrome(cmd.Context())
},
PostRun: func(cmd *cobra.Command, args []string) {
postRun()
},
Version: consts.Version,
}
)
// Execute runs the root cobra command, which will start the Navidrome server by calling the runNavidrome function.
func Execute() {
ctx, cancel := mainContext(context.Background())
defer cancel()
rootCmd.SetVersionTemplate(`{{println .Version}}`)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
if err := rootCmd.ExecuteContext(ctx); err != nil {
log.Fatal(err)
}
}
@@ -56,34 +61,56 @@ func preRun() {
if !noBanner {
println(resources.Banner())
}
conf.Load()
conf.Load(noBanner)
}
func runNavidrome() {
db.EnsureLatestVersion()
defer func() {
if err := db.Close(); err != nil {
log.Error("Error closing DB", err)
}
log.Info("Navidrome stopped, bye.")
}()
func postRun() {
log.Info("Navidrome stopped, bye.")
}
g, ctx := errgroup.WithContext(context.Background())
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
// it will cancel the context and exit gracefully.
func runNavidrome(ctx context.Context) {
defer db.Init(ctx)()
g, ctx := errgroup.WithContext(ctx)
g.Go(startServer(ctx))
g.Go(startSignaler(ctx))
g.Go(startSignaller(ctx))
g.Go(startScheduler(ctx))
g.Go(schedulePeriodicScan(ctx))
g.Go(startPlaybackServer(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBOptimizer(ctx))
if conf.Server.Scanner.Enabled {
g.Go(runInitialScan(ctx))
g.Go(startScanWatcher(ctx))
g.Go(schedulePeriodicScan(ctx))
} else {
log.Warn(ctx, "Automatic Scanning is DISABLED")
}
if err := g.Wait(); err != nil && !errors.Is(err, interrupted) {
if err := g.Wait(); err != nil {
log.Error("Fatal error in Navidrome. Aborting", err)
}
}
// mainContext returns a context that is cancelled when the process receives a signal to exit.
func mainContext(ctx context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(ctx,
os.Interrupt,
syscall.SIGHUP,
syscall.SIGTERM,
syscall.SIGABRT,
)
}
// startServer starts the Navidrome web server, adding all the necessary routers.
func startServer(ctx context.Context) func() error {
return func() error {
a := CreateServer(conf.Server.MusicFolder)
a := CreateServer()
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter())
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter())
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx))
a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter())
if conf.Server.LastFM.Enabled {
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
@@ -92,56 +119,212 @@ func startServer(ctx context.Context) func() error {
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
}
if conf.Server.Prometheus.Enabled {
// blocking call because takes <1ms but useful if fails
core.WriteInitialMetrics()
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
p := CreatePrometheus()
// blocking call because takes <100ms but useful if fails
p.WriteInitialMetrics(ctx)
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
}
if conf.Server.DevEnableProfiler {
a.MountRouter("Profiling", "/debug", middleware.Profiler())
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
}
return a.Run(ctx, fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
}
}
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
func schedulePeriodicScan(ctx context.Context) func() error {
return func() error {
schedule := conf.Server.ScanSchedule
schedule := conf.Server.Scanner.Schedule
if schedule == "" {
log.Warn("Periodic scan is DISABLED")
log.Info(ctx, "Periodic scan is DISABLED")
return nil
}
scanner := GetScanner()
s := CreateScanner(ctx)
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic scan", "schedule", schedule)
err := schedulerInstance.Add(schedule, func() {
_ = scanner.RescanAll(ctx, false)
_, err := s.ScanAll(ctx, false)
if err != nil {
log.Error(ctx, "Error executing periodic scan", err)
}
})
if err != nil {
log.Error("Error scheduling periodic scan", err)
log.Error(ctx, "Error scheduling periodic scan", err)
}
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
log.Debug("Executing initial scan")
if err := scanner.RescanAll(ctx, false); err != nil {
log.Error("Error executing initial scan", err)
}
log.Debug("Finished initial scan")
return nil
}
}
func startScheduler(ctx context.Context) func() error {
log.Info(ctx, "Starting scheduler")
schedulerInstance := scheduler.GetInstance()
func pidHashChanged(ds model.DataStore) (bool, error) {
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
if err != nil {
return false, err
}
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
if err != nil {
return false, err
}
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
}
func runInitialScan(ctx context.Context) func() error {
return func() error {
ds := CreateDataStore()
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
if err != nil {
return err
}
inProgress, err := ds.Library(ctx).ScanInProgress()
if err != nil {
return err
}
pidHasChanged, err := pidHashChanged(ds)
if err != nil {
return err
}
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded {
scanner := CreateScanner(ctx)
switch {
case fullScanRequired == "1":
log.Warn(ctx, "Full scan required after migration")
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
case pidHasChanged:
log.Warn(ctx, "PID config changed, performing full scan")
fullScanRequired = "1"
case inProgress:
log.Warn(ctx, "Resuming interrupted scan")
default:
log.Info("Executing initial scan")
}
_, err = scanner.ScanAll(ctx, fullScanRequired == "1")
if err != nil {
log.Error(ctx, "Scan failed", err)
} else {
log.Info(ctx, "Scan completed")
}
} else {
log.Debug(ctx, "Initial scan not needed")
}
return nil
}
}
func startScanWatcher(ctx context.Context) func() error {
return func() error {
if conf.Server.Scanner.WatcherWait == 0 {
log.Debug("Folder watcher is DISABLED")
return nil
}
w := CreateScanWatcher(ctx)
err := w.Run(ctx)
if err != nil {
log.Error("Error starting watcher", err)
}
return nil
}
}
func schedulePeriodicBackup(ctx context.Context) func() error {
return func() error {
schedule := conf.Server.Backup.Schedule
if schedule == "" {
log.Info(ctx, "Periodic backup is DISABLED")
return nil
}
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic backup", "schedule", schedule)
err := schedulerInstance.Add(schedule, func() {
start := time.Now()
path, err := db.Backup(ctx)
elapsed := time.Since(start)
if err != nil {
log.Error(ctx, "Error backing up database", "elapsed", elapsed, err)
return
}
log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path)
count, err := db.Prune(ctx)
if err != nil {
log.Error(ctx, "Error pruning database", "error", err)
} else if count > 0 {
log.Info(ctx, "Successfully pruned old files", "count", count)
} else {
log.Info(ctx, "No backups pruned")
}
})
return err
}
}
func scheduleDBOptimizer(ctx context.Context) func() error {
return func() error {
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
schedulerInstance := scheduler.GetInstance()
err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
if scanner.IsScanning() {
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
return
}
db.Optimize(ctx)
})
return err
}
}
// startScheduler starts the Navidrome scheduler, which is used to run periodic tasks.
func startScheduler(ctx context.Context) func() error {
return func() error {
log.Info(ctx, "Starting scheduler")
schedulerInstance := scheduler.GetInstance()
schedulerInstance.Run(ctx)
return nil
}
}
// startInsightsCollector starts the Navidrome Insight Collector, if configured.
func startInsightsCollector(ctx context.Context) func() error {
return func() error {
if !conf.Server.EnableInsightsCollector {
log.Info(ctx, "Insight Collector is DISABLED")
return nil
}
log.Info(ctx, "Starting Insight Collector")
select {
case <-time.After(conf.Server.DevInsightsInitialDelay):
case <-ctx.Done():
return nil
}
ic := CreateInsights()
ic.Run(ctx)
return nil
}
}
// startPlaybackServer starts the Navidrome playback server, if configured.
// It is responsible for the Jukebox functionality
func startPlaybackServer(ctx context.Context) func() error {
return func() error {
if !conf.Server.Jukebox.Enabled {
log.Debug("Jukebox is DISABLED")
return nil
}
log.Info(ctx, "Starting Jukebox service")
playbackInstance := GetPlaybackServer()
return playbackInstance.Run(ctx)
}
}
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
@@ -151,22 +334,31 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
rootCmd.PersistentFlags().BoolVarP(&noBanner, "nobanner", "n", false, `don't show banner`)
rootCmd.PersistentFlags().String("musicfolder", viper.GetString("musicfolder"), "folder where your music is stored")
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB, cache...), needs write access")
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB), needs write access")
rootCmd.PersistentFlags().String("cachefolder", viper.GetString("cachefolder"), "folder to store cache data (transcoding, images...), needs write access")
rootCmd.PersistentFlags().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace")
rootCmd.PersistentFlags().String("logfile", viper.GetString("logfile"), "log file path, if not set logs will be printed to stderr")
_ = viper.BindPFlag("musicfolder", rootCmd.PersistentFlags().Lookup("musicfolder"))
_ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder"))
_ = viper.BindPFlag("cachefolder", rootCmd.PersistentFlags().Lookup("cachefolder"))
_ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel"))
_ = viper.BindPFlag("logfile", rootCmd.PersistentFlags().Lookup("logfile"))
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind to")
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().StringP("address", "a", viper.GetString("address"), "IP address to bind")
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will use")
rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions")
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL (only the path part) to configure Navidrome behind a proxy (ex: /music)")
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
@@ -174,9 +366,13 @@ 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"))
_ = viper.BindPFlag("sessiontimeout", rootCmd.Flags().Lookup("sessiontimeout"))
_ = viper.BindPFlag("scaninterval", rootCmd.Flags().Lookup("scaninterval"))
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
_ = viper.BindPFlag("uiloginbackgroundurl", rootCmd.Flags().Lookup("uiloginbackgroundurl"))
_ = viper.BindPFlag("prometheus.enabled", rootCmd.Flags().Lookup("prometheus.enabled"))

View File

@@ -2,15 +2,28 @@ package cmd
import (
"context"
"encoding/gob"
"os"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/utils/pl"
"github.com/spf13/cobra"
)
var fullRescan bool
var (
fullScan bool
subprocess bool
)
func init() {
scanCmd.Flags().BoolVarP(&fullRescan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
rootCmd.AddCommand(scanCmd)
}
@@ -19,16 +32,53 @@ var scanCmd = &cobra.Command{
Short: "Scan music folder",
Long: "Scan music folder for updates",
Run: func(cmd *cobra.Command, args []string) {
runScanner()
runScanner(cmd.Context())
},
}
func runScanner() {
scanner := GetScanner()
_ = scanner.RescanAll(context.Background(), fullRescan)
if fullRescan {
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
for status := range pl.ReadOrDone(ctx, progress) {
if status.Warning != "" {
log.Warn(ctx, "Scan warning", "error", status.Warning)
}
if status.Error != "" {
log.Error(ctx, "Scan error", "error", status.Error)
}
// Discard the progress status, we only care about errors
}
if fullScan {
log.Info("Finished full rescan")
} else {
log.Info("Finished rescan")
}
}
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
encoder := gob.NewEncoder(os.Stdout)
for status := range pl.ReadOrDone(ctx, progress) {
err := encoder.Encode(status)
if err != nil {
log.Error(ctx, "Failed to encode status", err)
}
}
}
func runScanner(ctx context.Context) {
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
progress, err := scanner.CallScan(ctx, ds, artwork.NoopCacheWarmer(), pls, metrics.NewNoopInstance(), fullScan)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
}
// Wait for the scanner to finish
if subprocess {
trackScanAsSubprocess(ctx, progress)
} else {
trackScanInteractively(ctx, progress)
}
}

View File

@@ -1,27 +0,0 @@
//go:build windows || plan9
package cmd
import (
"context"
"os"
"os/signal"
"github.com/navidrome/navidrome/log"
)
func startSignaler(ctx context.Context) func() error {
log.Info(ctx, "Starting signaler")
return func() error {
var sigChan = make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
select {
case sig := <-sigChan:
log.Info(ctx, "Received termination signal", "signal", sig)
return interrupted
case <-ctx.Done():
return nil
}
}
}

14
cmd/signaller_nounix.go Normal file
View File

@@ -0,0 +1,14 @@
//go:build windows || plan9
package cmd
import (
"context"
)
// Windows and Plan9 don't support SIGUSR1, so we don't need to start a signaler
func startSignaller(ctx context.Context) func() error {
return func() error {
return nil
}
}

View File

@@ -14,35 +14,24 @@ import (
const triggerScanSignal = syscall.SIGUSR1
func startSignaler(ctx context.Context) func() error {
func startSignaller(ctx context.Context) func() error {
log.Info(ctx, "Starting signaler")
scanner := GetScanner()
scanner := CreateScanner(ctx)
return func() error {
var sigChan = make(chan os.Signal, 1)
signal.Notify(
sigChan,
os.Interrupt,
triggerScanSignal,
syscall.SIGHUP,
syscall.SIGTERM,
syscall.SIGABRT,
)
signal.Notify(sigChan, triggerScanSignal)
for {
select {
case sig := <-sigChan:
if sig != triggerScanSignal {
log.Info(ctx, "Received termination signal", "signal", sig)
return interrupted
}
log.Info(ctx, "Received signal, triggering a new scan", "signal", sig)
start := time.Now()
err := scanner.RescanAll(ctx, false)
_, err := scanner.ScanAll(ctx, false)
if err != nil {
log.Error(ctx, "Error scanning", err)
}
log.Info(ctx, "Triggered scan complete", "elapsed", time.Since(start).Round(100*time.Millisecond))
log.Info(ctx, "Triggered scan complete", "elapsed", time.Since(start))
case <-ctx.Done():
return nil
}

267
cmd/svc.go Normal file
View File

@@ -0,0 +1,267 @@
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/kardianos/service"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var (
svcStatusLabels = map[service.Status]string{
service.StatusUnknown: "Unknown",
service.StatusStopped: "Stopped",
service.StatusRunning: "Running",
}
installUser string
workingDirectory string
)
func init() {
svcCmd.AddCommand(buildInstallCmd())
svcCmd.AddCommand(buildUninstallCmd())
svcCmd.AddCommand(buildStartCmd())
svcCmd.AddCommand(buildStopCmd())
svcCmd.AddCommand(buildStatusCmd())
svcCmd.AddCommand(buildExecuteCmd())
rootCmd.AddCommand(svcCmd)
}
var svcCmd = &cobra.Command{
Use: "service",
Aliases: []string{"svc"},
Short: "Manage Navidrome as a service",
Long: fmt.Sprintf("Manage Navidrome as a service, using the OS service manager (%s)", service.Platform()),
Run: runServiceCmd,
}
type svcControl struct {
ctx context.Context
cancel context.CancelFunc
done chan struct{}
}
func (p *svcControl) Start(service.Service) error {
p.done = make(chan struct{})
p.ctx, p.cancel = context.WithCancel(context.Background())
go func() {
runNavidrome(p.ctx)
close(p.done)
}()
return nil
}
func (p *svcControl) Stop(service.Service) error {
log.Info("Stopping service")
p.cancel()
select {
case <-p.done:
log.Info("Service stopped gracefully")
case <-time.After(10 * time.Second):
log.Error("Service did not stop in time. Killing it.")
}
return nil
}
var svcInstance = sync.OnceValue(func() service.Service {
options := make(service.KeyValue)
options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = false
options["LogDirectory"] = conf.Server.DataFolder
options["SystemdScript"] = systemdScript
if conf.Server.LogFile != "" {
options["LogOutput"] = false
} else {
options["LogOutput"] = true
options["LogDirectory"] = conf.Server.DataFolder
}
svcConfig := &service.Config{
UserName: installUser,
Name: "navidrome",
DisplayName: "Navidrome",
Description: "Your Personal Streaming Service",
Dependencies: []string{
"After=remote-fs.target network.target",
},
WorkingDirectory: executablePath(),
Option: options,
}
arguments := []string{"service", "execute"}
if conf.Server.ConfigFile != "" {
arguments = append(arguments, "-c", conf.Server.ConfigFile)
}
svcConfig.Arguments = arguments
prg := &svcControl{}
svc, err := service.New(prg, svcConfig)
if err != nil {
log.Fatal(err)
}
return svc
})
func runServiceCmd(cmd *cobra.Command, _ []string) {
_ = cmd.Help()
}
func executablePath() string {
if workingDirectory != "" {
return workingDirectory
}
ex, err := os.Executable()
if err != nil {
log.Fatal(err)
}
return filepath.Dir(ex)
}
func buildInstallCmd() *cobra.Command {
runInstallCmd := func(_ *cobra.Command, _ []string) {
var err error
println("Installing service with:")
println(" working directory: " + executablePath())
println(" music folder: " + conf.Server.MusicFolder)
println(" data folder: " + conf.Server.DataFolder)
if conf.Server.LogFile != "" {
println(" log file: " + conf.Server.LogFile)
} else {
println(" logs folder: " + conf.Server.DataFolder)
}
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
if err != nil {
log.Fatal(err)
}
println(" config file: " + conf.Server.ConfigFile)
}
err = svcInstance().Install()
if err != nil {
log.Fatal(err)
}
println("Service installed. Use 'navidrome svc start' to start it.")
}
cmd := &cobra.Command{
Use: "install",
Short: "Install Navidrome service.",
Run: runInstallCmd,
}
cmd.Flags().StringVarP(&installUser, "user", "u", "", "user to run service")
cmd.Flags().StringVarP(&workingDirectory, "working-directory", "w", "", "working directory of service")
return cmd
}
func buildUninstallCmd() *cobra.Command {
return &cobra.Command{
Use: "uninstall",
Short: "Uninstall Navidrome service. Does not delete the music or data folders",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Uninstall()
if err != nil {
log.Fatal(err)
}
println("Service uninstalled. Music and data folders are still intact.")
},
}
}
func buildStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start Navidrome service",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Start()
if err != nil {
log.Fatal(err)
}
println("Service started. Use 'navidrome svc status' to check its status.")
},
}
}
func buildStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop Navidrome service",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Stop()
if err != nil {
log.Fatal(err)
}
println("Service stopped. Use 'navidrome svc status' to check its status.")
},
}
}
func buildStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show Navidrome service status",
Run: func(cmd *cobra.Command, args []string) {
status, err := svcInstance().Status()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Navidrome is %s.\n", svcStatusLabels[status])
},
}
}
func buildExecuteCmd() *cobra.Command {
return &cobra.Command{
Use: "execute",
Short: "Run navidrome as a service in the foreground (it is very unlikely you want to run this, you are better off running just navidrome)",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Run()
if err != nil {
log.Fatal(err)
}
},
}
}
const systemdScript = `[Unit]
Description={{.Description}}
ConditionFileIsExecutable={{.Path|cmdEscape}}
{{range $i, $dep := .Dependencies}}
{{$dep}} {{end}}
[Service]
StartLimitInterval=5
StartLimitBurst=10
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
{{if .UserName}}User={{.UserName}}{{end}}
{{if .Restart}}Restart={{.Restart}}{{end}}
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
TimeoutStopSec=20
RestartSec=120
EnvironmentFile=-/etc/sysconfig/{{.Name}}
DevicePolicy=closed
NoNewPrivileges=yes
PrivateTmp=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
ProtectSystem=full
[Install]
WantedBy=multi-user.target
`

View File

@@ -1,21 +1,26 @@
// 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 gen -tags "netgo"
//go:build !wireinject
// +build !wireinject
package cmd
import (
"context"
"github.com/google/wire"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
@@ -23,45 +28,60 @@ import (
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic"
"sync"
)
import (
_ "github.com/navidrome/navidrome/adapters/taglib"
)
// Injectors from wire_injectors.go:
func CreateServer(musicFolder string) *server.Server {
func CreateDataStore() model.DataStore {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
serverServer := server.New(dataStore)
return dataStore
}
func CreateServer() *server.Server {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
insights := metrics.GetInstance(dataStore)
serverServer := server.New(dataStore, broker, insights)
return serverServer
}
func CreateNativeAPIRouter() *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
share := core.NewShare(dataStore)
router := nativeapi.New(dataStore, broker, share)
playlists := core.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights)
return router
}
func CreateSubsonicAPIRouter() *subsonic.Router {
func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.New(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
agentsAgents := agents.GetAgents(dataStore)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
archiver := core.NewArchiver(mediaStreamer, dataStore)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
scanner := GetScanner()
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
share := core.NewShare(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker, share)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer)
return router
}
@@ -70,13 +90,14 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.New(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
agentsAgents := agents.GetAgents(dataStore)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
return router
}
@@ -94,34 +115,60 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
return router
}
func createScanner() scanner.Scanner {
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
insights := metrics.GetInstance(dataStore)
return insights
}
func CreatePrometheus() metrics.Metrics {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
return metricsMetrics
}
func CreateScanner(ctx context.Context) scanner.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
playlists := core.NewPlaylists(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.New(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
agentsAgents := agents.GetAgents(dataStore)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
return scannerScanner
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.NewWatcher(dataStore, scannerScanner)
return watcher
}
func GetPlaybackServer() playback.PlaybackServer {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
playbackServer := playback.GetInstance(dataStore)
return playbackServer
}
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
// Scanner must be a Singleton
var (
onceScanner sync.Once
scannerInstance scanner.Scanner
)
func GetScanner() scanner.Scanner {
onceScanner.Do(func() {
scannerInstance = createScanner()
})
return scannerInstance
}
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, metrics.NewPrometheusInstance, db.Db)

View File

@@ -3,14 +3,17 @@
package cmd
import (
"sync"
"context"
"github.com/google/wire"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
@@ -23,6 +26,7 @@ import (
var allProviders = wire.NewSet(
core.Set,
artwork.Set,
server.New,
subsonic.New,
nativeapi.New,
public.New,
@@ -30,12 +34,20 @@ var allProviders = wire.NewSet(
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
scanner.New,
scanner.NewWatcher,
metrics.NewPrometheusInstance,
db.Db,
)
func CreateServer(musicFolder string) *server.Server {
func CreateDataStore() model.DataStore {
panic(wire.Build(
allProviders,
))
}
func CreateServer() *server.Server {
panic(wire.Build(
server.New,
allProviders,
))
}
@@ -46,10 +58,9 @@ func CreateNativeAPIRouter() *nativeapi.Router {
))
}
func CreateSubsonicAPIRouter() *subsonic.Router {
func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
panic(wire.Build(
allProviders,
GetScanner,
))
}
@@ -71,22 +82,32 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
// Scanner must be a Singleton
var (
onceScanner sync.Once
scannerInstance scanner.Scanner
)
func GetScanner() scanner.Scanner {
onceScanner.Do(func() {
scannerInstance = createScanner()
})
return scannerInstance
}
func createScanner() scanner.Scanner {
func CreateInsights() metrics.Insights {
panic(wire.Build(
allProviders,
))
}
func CreatePrometheus() metrics.Metrics {
panic(wire.Build(
allProviders,
))
}
func CreateScanner(ctx context.Context) scanner.Scanner {
panic(wire.Build(
allProviders,
))
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
panic(wire.Build(
allProviders,
))
}
func GetPlaybackServer() playback.PlaybackServer {
panic(wire.Build(
allProviders,
scanner.New,
))
}

View File

@@ -0,0 +1,4 @@
package buildtags
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
// required build tags are disabled.

11
conf/buildtags/netgo.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build netgo
package buildtags
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
// file requires it.
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
var NETGO = true

View File

@@ -2,98 +2,152 @@ package conf
import (
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/go-viper/encoding/ini"
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/chain"
"github.com/robfig/cron/v3"
"github.com/spf13/viper"
)
type configOptions struct {
ConfigFile string
Address string
Port int
MusicFolder string
DataFolder string
DbPath string
LogLevel string
ScanInterval time.Duration
ScanSchedule string
SessionTimeout time.Duration
BaseURL string
UILoginBackgroundURL string
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
EnableArtworkPrecache bool
AutoImportPlaylists bool
PlaylistsPath string
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
RecentlyAddedByModTime bool
IgnoredArticles string
IndexGroups string
SubsonicArtistParticipations bool
FFmpegPath string
CoverArtPriority string
CoverJpegQuality int
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableSharing bool
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
Prometheus prometheusOptions
Scanner scannerOptions
ConfigFile string
Address string
Port int
UnixSocketPerm string
MusicFolder string
DataFolder string
CacheFolder string
DbPath string
LogLevel string
LogFile string
SessionTimeout time.Duration
BaseURL string
BasePath string
BaseHost string
BaseScheme string
TLSCert string
TLSKey string
UILoginBackgroundURL string
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
EnableInsightsCollector bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
AlbumPlayCountMode string
EnableArtworkPrecache bool
AutoImportPlaylists bool
DefaultPlaylistPublicVisibility bool
PlaylistsPath string
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
IndexGroups string
FFmpegPath string
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableSharing bool
ShareURL string
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
HTTPSecurityHeaders secureOptions
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
Backup backupOptions
PID pidOptions
Inspect inspectOptions
Subsonic subsonicOptions
Agents string
LastFM lastfmOptions
Spotify spotifyOptions
ListenBrainz listenBrainzOptions
Tags map[string]TagConf
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogSourceLine bool
DevLogLevels map[string]string
DevEnableProfiler bool
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevEnableBufferedScrobble bool
DevShowArtistPage bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
}
type scannerOptions struct {
Extractor string
GenreSeparators string
Enabled bool
Schedule string
WatcherWait time.Duration
ScanOnStartup bool
Extractor string
ArtistJoiner string
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
}
type subsonicOptions struct {
AppendSubtitle bool
ArtistParticipations bool
DefaultReportRealPath bool
LegacyClients string
}
type TagConf struct {
Ignore bool `yaml:"ignore"`
Aliases []string `yaml:"aliases"`
Type string `yaml:"type"`
MaxLength int `yaml:"maxLength"`
Split []string `yaml:"split"`
Album bool `yaml:"album"`
}
type lastfmOptions struct {
@@ -113,9 +167,41 @@ type listenBrainzOptions struct {
BaseURL string
}
type secureOptions struct {
CustomFrameOptionsValue string
}
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string
}
type AudioDeviceDefinition []string
type jukeboxOptions struct {
Enabled bool
Devices []AudioDeviceDefinition
Default string
AdminOnly bool
}
type backupOptions struct {
Count int
Path string
Schedule string
}
type pidOptions struct {
Track string
Album string
}
type inspectOptions struct {
Enabled bool
MaxRequests int
BacklogLimit int
BacklogTimeout int
}
var (
@@ -125,55 +211,154 @@ var (
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
Load()
err := viper.ReadInConfig()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
os.Exit(1)
}
Load(true)
}
func Load() {
func Load(noConfigDump bool) {
parseIniFileConfiguration()
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", "path", Server.DataFolder, err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
os.Exit(1)
}
if Server.CacheFolder == "" {
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
os.Exit(1)
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
}
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
os.Exit(1)
}
}
out := os.Stderr
if Server.LogFile != "" {
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
os.Exit(1)
}
log.SetOutput(out)
}
log.SetLevelString(Server.LogLevel)
log.SetLogLevels(Server.DevLogLevels)
log.SetLogSourceLine(Server.DevLogSourceLine)
log.SetRedacting(Server.EnableLogRedacting)
if err := validateScanSchedule(); err != nil {
err = chain.RunSequentially(
validateScanSchedule,
validateBackupSchedule,
validatePlaylistsPath,
)
if err != nil {
os.Exit(1)
}
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
os.Exit(1)
}
Server.BasePath = u.Path
u.Path = ""
u.RawQuery = ""
Server.BaseHost = u.Host
Server.BaseScheme = u.Scheme
}
// Print current configuration if log level is Debug
if log.CurrentLevel() >= log.LevelDebug {
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
_, _ = fmt.Fprintln(os.Stderr, prettyConf)
_, _ = fmt.Fprintln(out, prettyConf)
}
if !Server.EnableExternalServices {
disableExternalServices()
}
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
// Call init hooks
for _, hook := range hooks {
hook()
}
}
func logDeprecatedOptions(options ...string) {
for _, option := range options {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
if os.Getenv(envVar) != "" {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar))
}
if viper.InConfig(option) {
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option))
}
}
}
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level.
func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
var iniConfig map[string]interface{}
err := viper.Unmarshal(&iniConfig)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
os.Exit(1)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
}
}
func disableExternalServices() {
log.Info("All external integrations are DISABLED!")
Server.EnableInsightsCollector = false
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
Server.ListenBrainz.Enabled = false
@@ -183,33 +368,49 @@ func disableExternalServices() {
}
}
func validateScanSchedule() error {
if Server.ScanInterval != -1 {
log.Warn("ScanInterval is DEPRECATED. Please use ScanSchedule. See docs at https://navidrome.org/docs/usage/configuration-options/")
if Server.ScanSchedule != "@every 1m" {
log.Error("You cannot specify both ScanInterval and ScanSchedule, ignoring ScanInterval")
} else {
if Server.ScanInterval == 0 {
Server.ScanSchedule = ""
} else {
Server.ScanSchedule = fmt.Sprintf("@every %s", Server.ScanInterval)
}
log.Warn("Setting ScanSchedule", "schedule", Server.ScanSchedule)
func validatePlaylistsPath() error {
for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
_, err := doublestar.Match(path, "")
if err != nil {
log.Error("Invalid PlaylistsPath", "path", path, err)
return err
}
}
if Server.ScanSchedule == "0" || Server.ScanSchedule == "" {
Server.ScanSchedule = ""
return nil
}
func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
return nil
}
if _, err := time.ParseDuration(Server.ScanSchedule); err == nil {
Server.ScanSchedule = "@every " + Server.ScanSchedule
var err error
Server.Scanner.Schedule, err = validateSchedule(Server.Scanner.Schedule, "Scanner.Schedule")
return err
}
func validateBackupSchedule() error {
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
Server.Backup.Schedule = ""
return nil
}
var err error
Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "Backup.Schedule")
return err
}
func validateSchedule(schedule, field string) (string, error) {
if _, err := time.ParseDuration(schedule); err == nil {
schedule = "@every " + schedule
}
c := cron.New()
_, err := c.AddFunc(Server.ScanSchedule, func() {})
id, err := c.AddFunc(schedule, func() {})
if err != nil {
log.Error("Invalid ScanSchedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", Server.ScanSchedule, err)
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
} else {
c.Remove(id)
}
return err
return schedule, err
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
@@ -219,36 +420,45 @@ func AddHook(hook func()) {
func init() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
viper.SetDefault("datafolder", ".")
viper.SetDefault("loglevel", "info")
viper.SetDefault("logfile", "")
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")
viper.SetDefault("baseurl", "")
viper.SetDefault("tlscert", "")
viper.SetDefault("tlskey", "")
viper.SetDefault("uiloginbackgroundurl", consts.DefaultUILoginBackgroundURL)
viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
viper.SetDefault("enableartworkprecache", true)
viper.SetDefault("autoimportplaylists", true)
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
viper.SetDefault("defaultplaylistpublicvisibility", false)
viper.SetDefault("playlistspath", "")
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enableMediaFileCoverArt", true)
viper.SetDefault("enablemediafilecoverart", true)
viper.SetDefault("autotranscodedownload", false)
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)
viper.SetDefault("ffmpegpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)
@@ -258,7 +468,11 @@ func init() {
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true)
viper.SetDefault("enablelogredacting", true)
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
@@ -268,38 +482,79 @@ func init() {
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", "/metrics")
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
viper.SetDefault("jukebox.enabled", false)
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
viper.SetDefault("jukebox.default", "")
viper.SetDefault("jukebox.adminonly", true)
viper.SetDefault("scanner.enabled", true)
viper.SetDefault("scanner.schedule", "0")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.genreseparators", ";/,")
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
viper.SetDefault("scanner.scanonstartup", true)
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.legacyclients", "DSub")
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)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
viper.SetDefault("pid.track", consts.DefaultTrackPID)
viper.SetDefault("pid.album", consts.DefaultAlbumPID)
viper.SetDefault("inspect.enabled", true)
viper.SetDefault("inspect.maxrequests", 1)
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)
viper.SetDefault("devautocreateadminpassword", "")
viper.SetDefault("devautologinusername", "")
viper.SetDefault("devactivitypanel", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
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)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)
viper.SetDefault("devscannerthreads", 5)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true)
}
func InitConfig(cfgFile string) {
codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
cfgFile = getConfigFile(cfgFile)
if cfgFile != "" {
// Use config file from the flag.
@@ -323,9 +578,17 @@ func InitConfig(cfgFile string) {
}
}
// getConfigFile returns the path to the config file, either from the flag or from the environment variable.
// If it is defined in the environment variable, it will check if the file exists.
func getConfigFile(cfgFile string) string {
if cfgFile != "" {
return cfgFile
}
return os.Getenv("ND_CONFIGFILE")
cfgFile = os.Getenv("ND_CONFIGFILE")
if cfgFile != "" {
if _, err := os.Stat(cfgFile); err == nil {
return cfgFile
}
}
return ""
}

View File

@@ -0,0 +1,50 @@
package conf_test
import (
"fmt"
"path/filepath"
"testing"
. "github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/viper"
)
func TestConfiguration(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Configuration Suite")
}
var _ = Describe("Configuration", func() {
BeforeEach(func() {
// Reset viper configuration
viper.Reset()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
ResetConf()
})
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
InitConfig(filename)
// Load the configuration (with noConfigDump=true)
Load(true)
// Execute the format-specific assertions
Expect(Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
// The config file used should be the one we created
Expect(Server.ConfigFile).To(Equal(filename))
},
Entry("TOML format", "toml"),
Entry("YAML format", "yaml"),
Entry("INI format", "ini"),
Entry("JSON format", "json"),
)
})

5
conf/export_test.go Normal file
View File

@@ -0,0 +1,5 @@
package conf
func ResetConf() {
Server = &configOptions{}
}

48
conf/mime/mime_types.go Normal file
View File

@@ -0,0 +1,48 @@
package mime
import (
"mime"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
"gopkg.in/yaml.v3"
)
type mimeConf struct {
Types map[string]string `yaml:"types"`
Lossless []string `yaml:"lossless"`
}
var LosslessFormats []string
func initMimeTypes() {
// In some circumstances, Windows sets JS mime-type to `text/plain`!
_ = mime.AddExtensionType(".js", "text/javascript")
_ = mime.AddExtensionType(".css", "text/css")
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
f, err := resources.FS().Open("mime_types.yaml")
if err != nil {
log.Fatal("Fatal error opening mime_types.yaml", err)
}
defer f.Close()
var mimeConf mimeConf
err = yaml.NewDecoder(f).Decode(&mimeConf)
if err != nil {
log.Fatal("Fatal error parsing mime_types.yaml", err)
}
for ext, typ := range mimeConf.Types {
_ = mime.AddExtensionType(ext, typ)
}
for _, ext := range mimeConf.Lossless {
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
}
}
func init() {
conf.AddHook(initMimeTypes)
}

6
conf/testdata/cfg.ini vendored Normal file
View File

@@ -0,0 +1,6 @@
[default]
MusicFolder = /ini/music
UIWelcomeMessage = Welcome ini
[Tags]
Custom.Aliases = ini,test

12
conf/testdata/cfg.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
"Tags": {
"custom": {
"aliases": [
"json",
"test"
]
}
}
}

5
conf/testdata/cfg.toml vendored Normal file
View File

@@ -0,0 +1,5 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
[Tags.custom]
aliases = ["toml", "test"]

7
conf/testdata/cfg.yaml vendored Normal file
View File

@@ -0,0 +1,7 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
Tags:
custom:
aliases:
- yaml
- test

View File

@@ -1,26 +1,29 @@
package consts
import (
"crypto/md5"
"fmt"
"path/filepath"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/model/id"
)
const (
AppName = "navidrome"
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
InitialSetupFlagKey = "InitialSetup"
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
InitialSetupFlagKey = "InitialSetup"
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
JWTSecretKey = "JWTSecret"
JWTIssuer = "ND"
DefaultSessionTimeout = 24 * time.Hour
DefaultSessionTimeout = 48 * time.Hour
CookieExpiry = 365 * 24 * 3600 // One year
OptimizeDBSchedule = "@every 24h"
// DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option
// Never ever change this! Or it will break all Navidrome installations that don't set the config option
DefaultEncryptionKey = "just for obfuscation"
@@ -50,14 +53,16 @@ const (
ServerReadHeaderTimeout = 3 * time.Second
ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour
ArtistInfoTimeToLive = 24 * time.Hour
AlbumInfoTimeToLive = 7 * 24 * time.Hour
UpdateLastAccessFrequency = time.Minute
UpdatePlayerFrequency = time.Minute
I18nFolder = "i18n"
SkipScanFile = ".ndignore"
I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "placeholder.png"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
@@ -65,61 +70,98 @@ const (
DefaultHttpClientTimeOut = 10 * time.Second
DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b')
)
Zwsp = string('\u200b')
// Prometheus options
const (
PrometheusDefaultPath = "/metrics"
PrometheusAuthUser = "navidrome"
)
// Cache options
const (
TranscodingCacheDir = "cache/transcoding"
TranscodingCacheDir = "transcoding"
DefaultTranscodingCacheMaxItems = 0 // Unlimited
ImageCacheDir = "cache/images"
ImageCacheDir = "images"
DefaultImageCacheMaxItems = 0 // Unlimited
DefaultCacheSize = 100 * 1024 * 1024 // 100MB
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// Shared secrets (only add here "secrets" that can be public)
const (
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"
)
const (
//DefaultAlbumPID = "album_legacy"
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
PIDAlbumKey = "PIDAlbum"
PIDTrackKey = "PIDTrack"
)
const (
InsightsIDKey = "InsightsID"
InsightsEndpoint = "https://insights.navidrome.org/collect"
InsightsUpdateInterval = 24 * time.Hour
InsightsInitialDelay = 30 * time.Minute
)
var (
DefaultDownsamplingFormat = "opus"
DefaultTranscodings = []map[string]interface{}{
DefaultTranscodings = []struct {
Name string
TargetFormat string
DefaultBitRate int
Command string
}{
{
"name": "mp3 audio",
"targetFormat": "mp3",
"defaultBitRate": 192,
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -",
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
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:0 -b:a %bk -v 0 -c:a libopus -f opus -",
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
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:0 -b:a %bk -v 0 -c:a aac -f adts -",
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
}
DefaultPlaylistsPath = strings.Join([]string{".", "**/**"}, string(filepath.ListSeparator))
)
var (
VariousArtists = "Various Artists"
VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists))))
UnknownAlbum = "[Unknown Album]"
UnknownArtist = "[Unknown Artist]"
UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist))))
VariousArtists = "Various Artists"
// TODO This will be dynamic when using disambiguation
VariousArtistsID = "63sqASlAfjbGMuLP4JhnZU"
UnknownAlbum = "[Unknown Album]"
UnknownArtist = "[Unknown Artist]"
// TODO This will be dynamic when using disambiguation
UnknownArtistID = id.NewHash(strings.ToLower(UnknownArtist))
VariousArtistsMbzId = "89ad4ac3-39f7-470e-963a-56509c546377"
ServerStart = time.Now()
ArtistJoiner = " • "
)
var (
ServerStart = time.Now()
InContainer = func() bool {
// Check if the /.nddockerenv file exists
if _, err := os.Stat("/.nddockerenv"); err == nil {
return true
}
return false
}()
)

View File

@@ -1,64 +0,0 @@
package consts
import (
"mime"
"sort"
"strings"
)
type format struct {
typ string
lossless bool
}
var audioFormats = map[string]format{
".mp3": {typ: "audio/mpeg"},
".ogg": {typ: "audio/ogg"},
".oga": {typ: "audio/ogg"},
".opus": {typ: "audio/ogg"},
".aac": {typ: "audio/mp4"},
".alac": {typ: "audio/mp4", lossless: true},
".m4a": {typ: "audio/mp4"},
".m4b": {typ: "audio/mp4"},
".flac": {typ: "audio/flac", lossless: true},
".wav": {typ: "audio/x-wav", lossless: true},
".wma": {typ: "audio/x-ms-wma"},
".ape": {typ: "audio/x-monkeys-audio", lossless: true},
".mpc": {typ: "audio/x-musepack"},
".shn": {typ: "audio/x-shn", lossless: true},
".aif": {typ: "audio/x-aiff"},
".aiff": {typ: "audio/x-aiff"},
".m3u": {typ: "audio/x-mpegurl"},
".pls": {typ: "audio/x-scpls"},
".dsf": {typ: "audio/dsd", lossless: true},
".wv": {typ: "audio/x-wavpack", lossless: true},
".wvp": {typ: "audio/x-wavpack", lossless: true},
".mka": {typ: "audio/x-matroska"},
}
var imageFormats = map[string]string{
".gif": "image/gif",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".png": "image/png",
".bmp": "image/bmp",
}
var LosslessFormats []string
func init() {
for ext, fmt := range audioFormats {
_ = mime.AddExtensionType(ext, fmt.typ)
if fmt.lossless {
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
}
}
sort.Strings(LosslessFormats)
for ext, typ := range imageFormats {
_ = mime.AddExtensionType(ext, typ)
}
// In some circumstances, Windows sets JS mime-type to `text/plain`!
_ = mime.AddExtensionType(".js", "text/javascript")
_ = mime.AddExtensionType(".css", "text/css")
}

View File

@@ -0,0 +1,7 @@
https://your.website {
reverse_proxy * navidrome:4533 {
header_up Host {http.reverse_proxy.upstream.hostport}
header_up X-Forwarded-For {http.request.remote}
header_up X-Real-IP {http.reverse_proxy.upstream.port}
}
}

View File

@@ -0,0 +1,31 @@
version: '3.6'
volumes:
caddy_data:
navidrome_data:
services:
caddy:
container_name: "caddy"
image: caddy:2.6-alpine
restart: unless-stopped
read_only: true
volumes:
- "caddy_data:/data:rw"
- "./Caddyfile:/etc/caddy/Caddyfile:ro"
ports:
- "80:80"
- "443:443"
navidrome:
container_name: "navidrome"
image: deluan/navidrome:latest
restart: unless-stopped
read_only: true
# user: 1000:1000
ports:
- "4533:4533"
volumes:
- "navidrome_data:/data"
#- "/mnt/music:/music:ro"

View File

@@ -0,0 +1,51 @@
version: "3.6"
volumes:
traefik_data:
navidrome_data:
services:
traefik:
container_name: "traefik"
image: traefik:2.9
restart: unless-stopped
read_only: true
command:
- "--log.level=ERROR"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.tc.acme.tlschallenge=true"
#- "--certificatesresolvers.tc.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
- "--certificatesresolvers.tc.acme.email=foo@foo.com"
- "--certificatesresolvers.tc.acme.storage=/letsencrypt/acme.json"
ports:
- "443:443"
volumes:
- "traefik_data:/letsencrypt"
#- "/var/run/docker.sock:/var/run/docker.sock:ro"
navidrome:
container_name: "navidrome"
image: deluan/navidrome:latest
restart: unless-stopped
read_only: true
# user: 1000:1000
ports:
- "4533:4533"
environment:
ND_SCANINTERVAL: 6h
ND_LOGLEVEL: info
ND_SESSIONTIMEOUT: 168h
ND_BASEURL: ""
volumes:
- "navidrome_data:/data"
#- "/mnt/music:/music:ro"
labels:
- "traefik.enable=true"
- "traefik.http.routers.navidrome.rule=Host(`foo.com`)"
- "traefik.http.routers.navidrome.entrypoints=websecure"
- "traefik.http.routers.navidrome.tls=true"
- "traefik.http.routers.navidrome.tls.certresolver=tc"
- "traefik.http.services.navidrome.loadbalancer.server.port=4533"

View File

@@ -0,0 +1,18 @@
version: '3.6'
volumes:
navidrome_data:
services:
navidrome:
container_name: "navidrome"
image: deluan/navidrome:latest
restart: unless-stopped
read_only: true
# user: 1000:1000
ports:
- "4533:4533"
volumes:
- "navidrome_data:/data"
#- "/mnt/music:/music:ro"

View File

@@ -11,7 +11,7 @@
#
# navidrome_enable (bool): Set to YES to enable navidrome
# Default: NO
# navidrome_config (str): navidrome configration file
# navidrome_config (str): navidrome configuration file
# Default: /usr/local/etc/navidrome/config.toml
# navidrome_datafolder (str): navidrome Folder to store application data
# Default: www

11
contrib/k8s/README.md Normal file
View File

@@ -0,0 +1,11 @@
# Kubernetes
A couple things to keep in mind with this manifest:
1. This creates a namespace called `navidrome`. Adjust this as needed.
1. This manifest was created on [K3s](https://github.com/k3s-io/k3s), which uses its own storage provisioner called [local-path-provisioner](https://github.com/rancher/local-path-provisioner). Be sure to change the `storageClassName` of the `PersistentVolumeClaim` as needed.
1. The `PersistentVolumeClaim` sets up a 2Gi volume for Navidrome's database. Adjust this as needed.
1. Be sure to change the `image` tag from `ghcr.io/navidrome/navidrome:0.49.3` to whatever the newest version is.
1. This assumes your music is mounted on the host using `hostPath` at `/path/to/your/music/on/the/host`. Adjust this as needed.
1. The `Ingress` is already configured for `cert-manager` to obtain a Let's Encrypt TLS certificate and uses Traefik for routing. Adjust this as needed.
1. The `Ingress` presents the service at `navidrome.${SECRET_INTERNAL_DOMAIN_NAME}`, which needs to already be setup in DNS.

111
contrib/k8s/manifest.yml Normal file
View File

@@ -0,0 +1,111 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: navidrome
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: navidrome-data-pvc
namespace: navidrome
annotations:
volumeType: local
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
storageClassName: local-path
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: navidrome-deployment
namespace: navidrome
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app: navidrome
template:
metadata:
labels:
app: navidrome
spec:
containers:
- name: navidrome
image: ghcr.io/navidrome/navidrome:0.49.3
ports:
- containerPort: 4533
env:
- name: ND_SCANSCHEDULE
value: "12h"
- name: ND_SESSIONTIMEOUT
value: "24h"
- name: ND_LOGLEVEL
value: "info"
- name: ND_ENABLETRANSCODINGCONFIG
value: "false"
- name: ND_TRANSCODINGCACHESIZE
value: "512MB"
- name: ND_ENABLESTARRATING
value: "false"
- name: ND_ENABLEFAVOURITES
value: "false"
volumeMounts:
- name: data
mountPath: /data
- name: music
mountPath: /music
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: navidrome-data-pvc
- name: music
hostPath:
path: /path/to/your/music/on/the/host
type: Directory
---
apiVersion: v1
kind: Service
metadata:
name: navidrome-service
namespace: navidrome
spec:
type: ClusterIP
ports:
- name: http
targetPort: 4533
port: 4533
protocol: TCP
selector:
app: navidrome
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: navidrome-ingress
namespace: navidrome
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
rules:
- host: navidrome.${SECRET_INTERNAL_DOMAIN_NAME}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: navidrome-service
port:
number: 4533
tls:
- hosts:
- navidrome.${SECRET_INTERNAL_DOMAIN_NAME}
secretName: navidrome-tls

View File

@@ -11,15 +11,13 @@ WantedBy=multi-user.target
User=navidrome
Group=navidrome
Type=simple
ExecStart=/usr/bin/navidrome
ExecStart=/usr/bin/navidrome --configfile "/etc/navidrome/navidrome.toml"
StateDirectory=navidrome
WorkingDirectory=/var/lib/navidrome
TimeoutStopSec=20
KillMode=process
Restart=on-failure
EnvironmentFile=-/etc/sysconfig/navidrome
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
CapabilityBoundingSet=
DevicePolicy=closed
@@ -38,6 +36,7 @@ RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallFilter=setrlimit
SystemCallArchitectures=native
UMask=0066

View File

@@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/singleton"
)
type Agents struct {
@@ -17,22 +18,36 @@ type Agents struct {
agents []Interface
}
func New(ds model.DataStore) *Agents {
func GetAgents(ds model.DataStore) *Agents {
return singleton.GetInstance(func() *Agents {
return createAgents(ds)
})
}
func createAgents(ds model.DataStore) *Agents {
var order []string
if conf.Server.Agents != "" {
order = strings.Split(conf.Server.Agents, ",")
}
order = append(order, LocalAgentName)
var res []Interface
var enabled []string
for _, name := range order {
init, ok := Map[name]
if !ok {
log.Error("Agent not available. Check configuration", "name", name)
log.Error("Invalid agent. Check `Agents` configuration", "name", name, "conf", conf.Server.Agents)
continue
}
res = append(res, init(ds))
agent := init(ds)
if agent == nil {
log.Debug("Agent not available. Missing configuration?", "name", name)
continue
}
enabled = append(enabled, name)
res = append(res, agent)
}
log.Debug("List of agents enabled", "names", enabled)
return &Agents{ds: ds, agents: res}
}
@@ -134,7 +149,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

@@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
@@ -28,7 +29,7 @@ var _ = Describe("Agents", func() {
var ag *Agents
BeforeEach(func() {
conf.Server.Agents = ""
ag = New(ds)
ag = createAgents(ds)
})
It("calls the placeholder GetArtistImages", func() {
@@ -44,23 +45,25 @@ var _ = Describe("Agents", func() {
var mock *mockAgent
BeforeEach(func() {
mock = &mockAgent{}
Register("fake", func(ds model.DataStore) Interface {
return mock
})
Register("empty", func(ds model.DataStore) Interface {
return struct {
Interface
}{}
})
conf.Server.Agents = "empty,fake"
ag = New(ds)
Register("fake", func(model.DataStore) Interface { return mock })
Register("disabled", func(model.DataStore) Interface { return nil })
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
conf.Server.Agents = "empty,fake,disabled"
ag = createAgents(ds)
Expect(ag.AgentName()).To(Equal("agents"))
})
It("does not register disabled agents", func() {
ags := slice.Map(ag.agents, func(a Interface) string { return a.AgentName() })
// local agent is always appended to the end of the agents list
Expect(ags).To(HaveExactElements("empty", "fake", "local"))
Expect(ags).ToNot(ContainElement("disabled"))
})
Describe("GetArtistMBID", func() {
It("returns on first match", func() {
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
Expect(mock.Args).To(ConsistOf("123", "test"))
Expect(mock.Args).To(HaveExactElements("123", "test"))
})
It("returns empty if artist is Various Artists", func() {
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
@@ -78,7 +81,7 @@ var _ = Describe("Agents", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistMBID(ctx, "123", "test")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test"))
Expect(mock.Args).To(HaveExactElements("123", "test"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -91,7 +94,7 @@ var _ = Describe("Agents", func() {
Describe("GetArtistURL", func() {
It("returns on first match", func() {
Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("returns empty if artist is Various Artists", func() {
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
@@ -109,7 +112,7 @@ var _ = Describe("Agents", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -122,7 +125,7 @@ var _ = Describe("Agents", func() {
Describe("GetArtistBiography", func() {
It("returns on first match", func() {
Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("returns empty if artist is Various Artists", func() {
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
@@ -140,7 +143,7 @@ var _ = Describe("Agents", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -156,13 +159,13 @@ var _ = Describe("Agents", func() {
URL: "imageUrl",
Size: 100,
}}))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
Expect(err).To(MatchError("not found"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -178,13 +181,13 @@ var _ = Describe("Agents", func() {
Name: "Joe Dohn",
MBID: "mbid321",
}}))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -200,13 +203,13 @@ var _ = Describe("Agents", func() {
Name: "A Song",
MBID: "mbid444",
}}))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -236,13 +239,13 @@ var _ = Describe("Agents", func() {
},
},
}))
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -344,3 +347,11 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
},
}, nil
}
type emptyAgent struct {
Interface
}
func (e *emptyAgent) AgentName() string {
return "empty"
}

View File

@@ -3,18 +3,22 @@ package lastfm
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"github.com/andybalholm/cascadia"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/cache"
"golang.org/x/net/html"
)
const (
@@ -28,15 +32,19 @@ var ignoredBiographies = []string{
}
type lastfmAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
apiKey string
secret string
lang string
client *client
ds model.DataStore
sessionKeys *agents.SessionKeys
apiKey string
secret string
lang string
client *client
getInfoMutex sync.Mutex
}
func lastFMConstructor(ds model.DataStore) *lastfmAgent {
if !conf.Server.LastFM.Enabled || conf.Server.LastFM.ApiKey == "" || conf.Server.LastFM.Secret == "" {
return nil
}
l := &lastfmAgent{
ds: ds,
lang: conf.Server.LastFM.Language,
@@ -47,7 +55,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
return l
}
@@ -104,7 +112,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
}
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, "")
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
@@ -115,7 +123,7 @@ func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string)
}
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, mbid)
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
@@ -126,7 +134,7 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (
}
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, mbid)
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return "", err
}
@@ -143,7 +151,7 @@ func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid str
}
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
resp, err := l.callArtistGetSimilar(ctx, name, mbid, limit)
resp, err := l.callArtistGetSimilar(ctx, name, limit)
if err != nil {
return nil, err
}
@@ -161,7 +169,7 @@ func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid stri
}
func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
resp, err := l.callArtistGetTopTracks(ctx, artistName, mbid, count)
resp, err := l.callArtistGetTopTracks(ctx, artistName, count)
if err != nil {
return nil, err
}
@@ -178,13 +186,55 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
hc := http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
defer resp.Body.Close()
node, err := html.Parse(resp.Body)
if err != nil {
return nil, fmt.Errorf("parse html: %w", err)
}
var res []agents.ExternalImage
n := cascadia.Query(node, artistOpenGraphQuery)
if n == nil {
return res, nil
}
for _, attr := range n.Attr {
if attr.Key == "content" {
res = []agents.ExternalImage{
{URL: attr.Val},
}
break
}
}
return res, nil
}
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
log.Warn(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
return l.callAlbumGetInfo(ctx, name, artist, "")
}
@@ -199,48 +249,31 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s
return a, nil
}
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) {
a, err := l.client.artistGetInfo(ctx, name, mbid)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && a.Name == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Warn(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
return l.callArtistGetInfo(ctx, name, "")
}
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) {
l.getInfoMutex.Lock()
defer l.getInfoMutex.Unlock()
a, err := l.client.artistGetInfo(ctx, name)
if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, "mbid", mbid, err)
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err
}
return a, nil
}
func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, mbid string, limit int) ([]Artist, error) {
s, err := l.client.artistGetSimilar(ctx, name, mbid, limit)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && s.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Warn(ctx, "LastFM/artist.getSimilar could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
return l.callArtistGetSimilar(ctx, name, "", limit)
}
func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, limit int) ([]Artist, error) {
s, err := l.client.artistGetSimilar(ctx, name, limit)
if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, "mbid", mbid, err)
log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, err)
return nil, err
}
return s.Artists, nil
}
func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mbid string, count int) ([]Track, error) {
t, err := l.client.artistGetTopTracks(ctx, artistName, mbid, count)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && t.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Warn(ctx, "LastFM/artist.getTopTracks could not find artist by mbid, trying again", "artist", artistName, "mbid", mbid)
return l.callArtistGetTopTracks(ctx, artistName, "", count)
}
func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName string, count int) ([]Track, error) {
t, err := l.client.artistGetTopTracks(ctx, artistName, count)
if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, "mbid", mbid, err)
log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, err)
return nil, err
}
return t.Track, nil
@@ -257,13 +290,13 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzTrackID,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
albumArtist: track.AlbumArtist,
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
return scrobbler.ErrUnrecoverable
return errors.Join(err, scrobbler.ErrUnrecoverable)
}
return nil
}
@@ -271,7 +304,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
return errors.Join(err, scrobbler.ErrNotAuthorized)
}
if s.Duration <= 30 {
@@ -283,7 +316,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzTrackID,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
albumArtist: s.AlbumArtist,
timestamp: s.TimeStamp,
@@ -295,12 +328,12 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
isLastFMError := errors.As(err, &lfErr)
if !isLastFMError {
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
return scrobbler.ErrRetryLater
return errors.Join(err, scrobbler.ErrRetryLater)
}
if lfErr.Code == 11 || lfErr.Code == 16 {
return scrobbler.ErrRetryLater
return errors.Join(err, scrobbler.ErrRetryLater)
}
return scrobbler.ErrUnrecoverable
return errors.Join(err, scrobbler.ErrUnrecoverable)
}
func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
@@ -310,13 +343,19 @@ 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)
})
}
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
})
})
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
@@ -30,16 +31,38 @@ var _ = Describe("lastfmAgent", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
conf.Server.LastFM.Enabled = true
conf.Server.LastFM.ApiKey = "123"
conf.Server.LastFM.Secret = "secret"
})
Describe("lastFMConstructor", func() {
It("uses configured api key and language", func() {
conf.Server.LastFM.ApiKey = "123"
conf.Server.LastFM.Secret = "secret"
conf.Server.LastFM.Language = "pt"
agent := lastFMConstructor(ds)
Expect(agent.apiKey).To(Equal("123"))
Expect(agent.secret).To(Equal("secret"))
Expect(agent.lang).To(Equal("pt"))
When("Agent is properly configured", func() {
It("uses configured api key and language", func() {
conf.Server.LastFM.Language = "pt"
agent := lastFMConstructor(ds)
Expect(agent.apiKey).To(Equal("123"))
Expect(agent.secret).To(Equal("secret"))
Expect(agent.lang).To(Equal("pt"))
})
})
When("Agent is disabled", func() {
It("returns nil", func() {
conf.Server.LastFM.Enabled = false
Expect(lastFMConstructor(ds)).To(BeNil())
})
})
When("ApiKey is empty", func() {
It("returns nil", func() {
conf.Server.LastFM.ApiKey = ""
Expect(lastFMConstructor(ds)).To(BeNil())
})
})
When("Secret is empty", func() {
It("returns nil", func() {
conf.Server.LastFM.Secret = ""
Expect(lastFMConstructor(ds)).To(BeNil())
})
})
})
@@ -56,48 +79,25 @@ var _ = Describe("lastfmAgent", func() {
It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
})
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
Context("MBID non existent in Last.fm", func() {
It("calls again when the response is artist == [unknown]", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, _ = agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")
Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
It("calls again when last.fm returns an error 6", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, _ = agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234")
Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
})
@@ -114,51 +114,28 @@ var _ = Describe("lastfmAgent", func() {
It("returns similar artists", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Artist{
Expect(agent.GetSimilarArtists(ctx, "123", "U2", "", 2)).To(Equal([]agents.Artist{
{Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
{Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
})
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
Context("MBID non existent in Last.fm", func() {
It("calls again when the response is artist == [unknown]", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, _ = agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)
Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
It("calls again when last.fm returns an error 6", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, _ = agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)
Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
})
@@ -175,51 +152,28 @@ var _ = Describe("lastfmAgent", func() {
It("returns top songs", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Song{
Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)).To(Equal([]agents.Song{
{Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
{Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
})
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
Context("MBID non existent in Last.fm", func() {
It("calls again when the response is artist == [unknown]", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, _ = agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)
Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
It("calls again when last.fm returns an error 6", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, _ = agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)
Expect(httpClient.RequestCount).To(Equal(2))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
})
@@ -234,14 +188,14 @@ var _ = Describe("lastfmAgent", func() {
agent = lastFMConstructor(ds)
agent.client = client
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzTrackID: "mbz-123",
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
}
})
@@ -262,7 +216,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
})
It("returns ErrNotAuthorized if user is not linked", func() {
@@ -289,7 +243,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
})

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
@@ -65,7 +65,9 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{}
resp := map[string]interface{}{
"apiKey": s.apiKey,
}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
@@ -89,13 +91,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 +106,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 (
@@ -59,11 +59,10 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
return &response.Album, nil
}
func (c *client) artistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) {
func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("artist", name)
params.Add("mbid", mbid)
params.Add("lang", c.lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@@ -72,11 +71,10 @@ func (c *client) artistGetInfo(ctx context.Context, name string, mbid string) (*
return &response.Artist, nil
}
func (c *client) artistGetSimilar(ctx context.Context, name string, mbid string, limit int) (*SimilarArtists, error) {
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
params := url.Values{}
params.Add("method", "artist.getSimilar")
params.Add("artist", name)
params.Add("mbid", mbid)
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@@ -85,11 +83,10 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, mbid string,
return &response.SimilarArtists, nil
}
func (c *client) artistGetTopTracks(ctx context.Context, name string, mbid string, limit int) (*TopTracks, error) {
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
params := url.Values{}
params.Add("method", "artist.getTopTracks")
params.Add("artist", name)
params.Add("mbid", mbid)
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {

View File

@@ -42,10 +42,10 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
artist, err := client.artistGetInfo(context.Background(), "U2", "123")
artist, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(BeNil())
Expect(artist.Name).To(Equal("U2"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=123&method=artist.getInfo"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
})
It("fails if Last.fm returns an http status != 200", func() {
@@ -54,7 +54,7 @@ var _ = Describe("client", func() {
StatusCode: 500,
}
_, err := client.artistGetInfo(context.Background(), "U2", "123")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError("last.fm http status: (500)"))
})
@@ -64,7 +64,7 @@ var _ = Describe("client", func() {
StatusCode: 400,
}
_, err := client.artistGetInfo(context.Background(), "U2", "123")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
})
@@ -74,14 +74,14 @@ var _ = Describe("client", func() {
StatusCode: 200,
}
_, err := client.artistGetInfo(context.Background(), "U2", "123")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
})
It("fails if HttpClient.Do() returns error", func() {
httpClient.Err = errors.New("generic error")
_, err := client.artistGetInfo(context.Background(), "U2", "123")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError("generic error"))
})
@@ -91,7 +91,7 @@ var _ = Describe("client", func() {
StatusCode: 200,
}
_, err := client.artistGetInfo(context.Background(), "U2", "123")
_, err := client.artistGetInfo(context.Background(), "U2")
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
})
@@ -102,10 +102,10 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.artistGetSimilar(context.Background(), "U2", "123", 2)
similar, err := client.artistGetSimilar(context.Background(), "U2", 2)
Expect(err).To(BeNil())
Expect(len(similar.Artists)).To(Equal(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&mbid=123&method=artist.getSimilar"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
})
})
@@ -114,10 +114,10 @@ var _ = Describe("client", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
top, err := client.artistGetTopTracks(context.Background(), "U2", "123", 2)
top, err := client.artistGetTopTracks(context.Background(), "U2", 2)
Expect(err).To(BeNil())
Expect(len(top.Track)).To(Equal(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&mbid=123&method=artist.getTopTracks"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
})
})

View File

@@ -11,7 +11,8 @@ import (
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/slice"
)
const (
@@ -35,7 +36,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.baseURL, chc)
return l
}
@@ -45,6 +46,12 @@ func (l *listenBrainzAgent) AgentName() string {
}
func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
artistMBIDs := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
return p.MbzArtistID
})
artistNames := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
return p.Name
})
li := listenInfo{
TrackMetadata: trackMetadata{
ArtistName: track.Artist,
@@ -54,9 +61,12 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
SubmissionClient: consts.AppName,
SubmissionClientVersion: consts.Version,
TrackNumber: track.TrackNumber,
ArtistMbzIDs: []string{track.MbzArtistID},
TrackMbzID: track.MbzTrackID,
ReleaseMbID: track.MbzAlbumID,
ArtistNames: artistNames,
ArtistMBIDs: artistMBIDs,
RecordingMBID: track.MbzRecordingID,
ReleaseMBID: track.MbzAlbumID,
ReleaseGroupMBID: track.MbzReleaseGroupID,
DurationMs: int(track.Duration * 1000),
},
},
}
@@ -66,14 +76,14 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
return errors.Join(err, scrobbler.ErrNotAuthorized)
}
li := l.formatListen(track)
err = l.client.updateNowPlaying(ctx, sk, li)
if err != nil {
log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err)
return scrobbler.ErrUnrecoverable
return errors.Join(err, scrobbler.ErrUnrecoverable)
}
return nil
}
@@ -81,7 +91,7 @@ func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track
func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
return errors.Join(err, scrobbler.ErrNotAuthorized)
}
li := l.formatListen(&s.MediaFile)
@@ -95,12 +105,12 @@ func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrob
isListenBrainzError := errors.As(err, &lbErr)
if !isListenBrainzError {
log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
return scrobbler.ErrRetryLater
return errors.Join(err, scrobbler.ErrRetryLater)
}
if lbErr.Code == 500 || lbErr.Code == 503 {
return scrobbler.ErrRetryLater
return errors.Join(err, scrobbler.ErrRetryLater)
}
return scrobbler.ErrUnrecoverable
return errors.Join(err, scrobbler.ErrUnrecoverable)
}
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {

View File

@@ -32,23 +32,26 @@ var _ = Describe("listenBrainzAgent", func() {
agent = listenBrainzConstructor(ds)
agent.client = newClient("http://localhost:8080", httpClient)
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzTrackID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzArtistID: "mbz-789",
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzRecordingID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzReleaseGroupID: "mbz-789",
Duration: 142.2,
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
{Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
},
},
}
})
Describe("formatListen", func() {
It("constructs the listenInfo properly", func() {
var idArtistId = func(element interface{}) string {
return element.(string)
}
lr := agent.formatListen(track)
Expect(lr).To(MatchAllFields(Fields{
"ListenedAt": Equal(0),
@@ -60,11 +63,12 @@ var _ = Describe("listenBrainzAgent", func() {
"SubmissionClient": Equal(consts.AppName),
"SubmissionClientVersion": Equal(consts.Version),
"TrackNumber": Equal(track.TrackNumber),
"TrackMbzID": Equal(track.MbzTrackID),
"ReleaseMbID": Equal(track.MbzAlbumID),
"ArtistMbzIDs": MatchAllElements(idArtistId, Elements{
"mbz-789": Equal(track.MbzArtistID),
}),
"RecordingMBID": Equal(track.MbzRecordingID),
"ReleaseMBID": Equal(track.MbzAlbumID),
"ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
"ArtistNames": ConsistOf("Artist 1", "Artist 2"),
"ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
"DurationMs": Equal(142200),
}),
}),
}))

View File

@@ -76,9 +76,12 @@ type additionalInfo struct {
SubmissionClient string `json:"submission_client,omitempty"`
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
TrackNumber int `json:"tracknumber,omitempty"`
TrackMbzID string `json:"track_mbid,omitempty"`
ArtistMbzIDs []string `json:"artist_mbids,omitempty"`
ReleaseMbID string `json:"release_mbid,omitempty"`
ArtistNames []string `json:"artist_names,omitempty"`
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
ReleaseMBID string `json:"release_mbid,omitempty"`
ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
}
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {

View File

@@ -74,10 +74,12 @@ var _ = Describe("client", func() {
TrackName: "Track Title",
ReleaseName: "Track Album",
AdditionalInfo: additionalInfo{
TrackNumber: 1,
TrackMbzID: "mbz-123",
ArtistMbzIDs: []string{"mbz-789"},
ReleaseMbID: "mbz-456",
TrackNumber: 1,
ArtistNames: []string{"Artist 1", "Artist 2"},
ArtistMBIDs: []string{"mbz-789", "mbz-012"},
RecordingMBID: "mbz-123",
ReleaseMBID: "mbz-456",
DurationMs: 142200,
},
},
}

2
core/agents/mcp/mcp-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
mcp-server
*.wasm

View File

@@ -0,0 +1,17 @@
# MCP Server (Proof of Concept)
This directory contains the source code for the `mcp-server`, a simple server implementation used as a proof-of-concept (PoC) for the Navidrome Plugin/MCP agent system.
This server is designed to be compiled into a WebAssembly (WASM) module (`.wasm`) using the `wasip1` target.
## Compilation
To compile the server into a WASM module (`mcp-server.wasm`), navigate to this directory in your terminal and run the following command:
```bash
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -o mcp-server.wasm .
```
**Note:** This command compiles the WASM module _without_ the `netgo` tag. Networking operations (like HTTP requests) are expected to be handled by host functions provided by the embedding application (Navidrome's `MCPAgent`) rather than directly within the WASM module itself.
Place the resulting `mcp-server.wasm` file where the Navidrome `MCPAgent` expects it (currently configured via the `McpServerPath` constant in `core/agents/mcp/mcp_agent.go`).

View File

@@ -0,0 +1,172 @@
package main
import (
"context"
"encoding/json" // Reusing ErrNotFound from wikidata.go (implicitly via main)
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
)
const dbpediaEndpoint = "https://dbpedia.org/sparql"
// Default timeout for DBpedia requests
const defaultDbpediaTimeout = 20 * time.Second
// Can potentially reuse SparqlResult, SparqlBindings, SparqlValue from wikidata.go
// if the structure is identical. Assuming it is for now.
// GetArtistBioFromDBpedia queries DBpedia for an artist's abstract using their name.
func GetArtistBioFromDBpedia(fetcher Fetcher, ctx context.Context, name string) (string, error) {
log.Printf("[MCP] Debug: GetArtistBioFromDBpedia called for name: %s", name)
if name == "" {
log.Printf("[MCP] Error: GetArtistBioFromDBpedia requires a name.")
return "", fmt.Errorf("name is required to query DBpedia by name")
}
// Escape name for SPARQL query literal
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
// SPARQL query using DBpedia ontology (dbo)
// Prefixes are recommended but can be omitted if endpoint resolves them.
// Searching case-insensitively on the label.
// Filtering for dbo:MusicalArtist or dbo:Band.
// Selecting the English abstract.
sparqlQuery := fmt.Sprintf(`
PREFIX dbo: <http://dbpedia.org/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
SELECT DISTINCT ?abstract WHERE {
?artist rdfs:label ?nameLabel .
FILTER(LCASE(STR(?nameLabel)) = LCASE("%s") && LANG(?nameLabel) = "en")
# Ensure it's a musical artist or band
{ ?artist rdf:type dbo:MusicalArtist } UNION { ?artist rdf:type dbo:Band }
?artist dbo:abstract ?abstract .
FILTER(LANG(?abstract) = "en")
} LIMIT 1`, escapedName)
// Prepare and execute HTTP request
queryValues := url.Values{}
queryValues.Set("query", sparqlQuery)
queryValues.Set("format", "application/sparql-results+json") // DBpedia standard format
reqURL := fmt.Sprintf("%s?%s", dbpediaEndpoint, queryValues.Encode())
log.Printf("[MCP] Debug: DBpedia Bio Request URL: %s", reqURL)
timeout := defaultDbpediaTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
log.Printf("[MCP] Debug: Fetching from DBpedia with timeout: %v", timeout)
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
if err != nil {
log.Printf("[MCP] Error: Fetcher failed for DBpedia bio request (name: '%s'): %v", name, err)
return "", fmt.Errorf("failed to execute DBpedia request: %w", err)
}
if statusCode != http.StatusOK {
log.Printf("[MCP] Error: DBpedia bio query failed for name '%s' with status %d: %s", name, statusCode, string(bodyBytes))
return "", fmt.Errorf("DBpedia query failed with status %d: %s", statusCode, string(bodyBytes))
}
log.Printf("[MCP] Debug: DBpedia bio query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
var result SparqlResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
// Try reading the raw body for debugging if JSON parsing fails
// (Seek back to the beginning might be needed if already read for error)
// For simplicity, just return the parsing error now.
log.Printf("[MCP] Error: Failed to decode DBpedia bio response for name '%s': %v", name, err)
return "", fmt.Errorf("failed to decode DBpedia response: %w", err)
}
// Extract the abstract
if len(result.Results.Bindings) > 0 {
if abstractVal, ok := result.Results.Bindings[0]["abstract"]; ok {
log.Printf("[MCP] Debug: Found DBpedia abstract for '%s'.", name)
return abstractVal.Value, nil
}
}
// Use the shared ErrNotFound
log.Printf("[MCP] Warn: No abstract found on DBpedia for name '%s'.", name)
return "", ErrNotFound
}
// GetArtistWikipediaURLFromDBpedia queries DBpedia for an artist's Wikipedia URL using their name.
func GetArtistWikipediaURLFromDBpedia(fetcher Fetcher, ctx context.Context, name string) (string, error) {
log.Printf("[MCP] Debug: GetArtistWikipediaURLFromDBpedia called for name: %s", name)
if name == "" {
log.Printf("[MCP] Error: GetArtistWikipediaURLFromDBpedia requires a name.")
return "", fmt.Errorf("name is required to query DBpedia by name for URL")
}
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
// SPARQL query using foaf:isPrimaryTopicOf
sparqlQuery := fmt.Sprintf(`
PREFIX dbo: <http://dbpedia.org/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
SELECT DISTINCT ?wikiPage WHERE {
?artist rdfs:label ?nameLabel .
FILTER(LCASE(STR(?nameLabel)) = LCASE("%s") && LANG(?nameLabel) = "en")
{ ?artist rdf:type dbo:MusicalArtist } UNION { ?artist rdf:type dbo:Band }
?artist foaf:isPrimaryTopicOf ?wikiPage .
# Ensure it links to the English Wikipedia
FILTER(STRSTARTS(STR(?wikiPage), "https://en.wikipedia.org/"))
} LIMIT 1`, escapedName)
// Prepare and execute HTTP request (similar structure to bio query)
queryValues := url.Values{}
queryValues.Set("query", sparqlQuery)
queryValues.Set("format", "application/sparql-results+json")
reqURL := fmt.Sprintf("%s?%s", dbpediaEndpoint, queryValues.Encode())
log.Printf("[MCP] Debug: DBpedia URL Request URL: %s", reqURL)
timeout := defaultDbpediaTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
log.Printf("[MCP] Debug: Fetching DBpedia URL with timeout: %v", timeout)
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
if err != nil {
log.Printf("[MCP] Error: Fetcher failed for DBpedia URL request (name: '%s'): %v", name, err)
return "", fmt.Errorf("failed to execute DBpedia URL request: %w", err)
}
if statusCode != http.StatusOK {
log.Printf("[MCP] Error: DBpedia URL query failed for name '%s' with status %d: %s", name, statusCode, string(bodyBytes))
return "", fmt.Errorf("DBpedia URL query failed with status %d: %s", statusCode, string(bodyBytes))
}
log.Printf("[MCP] Debug: DBpedia URL query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
var result SparqlResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
log.Printf("[MCP] Error: Failed to decode DBpedia URL response for name '%s': %v", name, err)
return "", fmt.Errorf("failed to decode DBpedia URL response: %w", err)
}
// Extract the URL
if len(result.Results.Bindings) > 0 {
if pageVal, ok := result.Results.Bindings[0]["wikiPage"]; ok {
log.Printf("[MCP] Debug: Found DBpedia Wikipedia URL for '%s': %s", name, pageVal.Value)
return pageVal.Value, nil
}
}
log.Printf("[MCP] Warn: No Wikipedia URL found on DBpedia for name '%s'.", name)
return "", ErrNotFound
}

View File

@@ -0,0 +1,16 @@
package main
import (
"context"
"time"
)
// Fetcher defines an interface for making HTTP requests, abstracting
// over native net/http and WASM host functions.
type Fetcher interface {
// Fetch performs an HTTP request.
// Returns the status code, response body, and any error encountered.
// Note: Implementations should aim to return the body even on non-2xx status codes
// if the body was successfully read, allowing callers to potentially inspect it.
Fetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error)
}

View File

@@ -0,0 +1,83 @@
//go:build !wasm
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"time"
)
type nativeFetcher struct {
// We could hold a shared client, but creating one per request
// with the specific timeout is simpler for this adapter.
}
// Ensure nativeFetcher implements Fetcher
var _ Fetcher = (*nativeFetcher)(nil)
// NewFetcher creates the default native HTTP fetcher.
func NewFetcher() Fetcher {
log.Println("[MCP] Debug: Using Native HTTP fetcher")
return &nativeFetcher{}
}
func (nf *nativeFetcher) Fetch(ctx context.Context, method, urlStr string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
log.Printf("[MCP] Debug: Native Fetch: Method=%s, URL=%s, Timeout=%v", method, urlStr, timeout)
// Create a client with the specific timeout for this request
client := &http.Client{Timeout: timeout}
var bodyReader io.Reader
if requestBody != nil {
bodyReader = bytes.NewReader(requestBody)
}
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
if err != nil {
log.Printf("[MCP] Error: Native Fetch failed to create request: %v", err)
return 0, nil, fmt.Errorf("failed to create native request: %w", err)
}
// Set headers consistent with previous direct client usage
req.Header.Set("Accept", "application/sparql-results+json, application/json")
// Note: Specific User-Agent was set per call site previously, might need adjustment
// if different user agents are desired per service.
req.Header.Set("User-Agent", "MCPGoServerExample/0.1 (Native Client)")
log.Printf("[MCP] Debug: Native Fetch executing request...")
resp, err := client.Do(req)
if err != nil {
// Let context cancellation errors pass through
if ctx.Err() != nil {
log.Printf("[MCP] Debug: Native Fetch context cancelled: %v", ctx.Err())
return 0, nil, ctx.Err()
}
log.Printf("[MCP] Error: Native Fetch HTTP request failed: %v", err)
return 0, nil, fmt.Errorf("native HTTP request failed: %w", err)
}
defer resp.Body.Close()
statusCode = resp.StatusCode
log.Printf("[MCP] Debug: Native Fetch received status code: %d", statusCode)
responseBodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
// Still return status code if body read fails
log.Printf("[MCP] Error: Native Fetch failed to read response body: %v", readErr)
return statusCode, nil, fmt.Errorf("failed to read native response body: %w", readErr)
}
responseBody = responseBodyBytes
log.Printf("[MCP] Debug: Native Fetch read %d bytes from response body", len(responseBodyBytes))
// Mimic behavior of returning body even on error status
if statusCode < 200 || statusCode >= 300 {
log.Printf("[MCP] Warn: Native Fetch request failed with status %d. Body: %s", statusCode, string(responseBody))
return statusCode, responseBody, fmt.Errorf("native request failed with status %d", statusCode)
}
log.Printf("[MCP] Debug: Native Fetch completed successfully.")
return statusCode, responseBody, nil
}

View File

@@ -0,0 +1,171 @@
//go:build wasm
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"unsafe"
)
// --- WASM Host Function Import --- (Copied from user prompt)
//go:wasmimport env http_fetch
//go:noescape
func http_fetch(
// Request details
urlPtr, urlLen uint32,
methodPtr, methodLen uint32,
bodyPtr, bodyLen uint32,
timeoutMillis uint32,
// Result pointers
resultStatusPtr uint32,
resultBodyPtr uint32, resultBodyCapacity uint32, resultBodyLenPtr uint32,
resultErrorPtr uint32, resultErrorCapacity uint32, resultErrorLenPtr uint32,
) uint32 // 0 on success, 1 on host error
// --- Go Wrapper for Host Function --- (Copied from user prompt)
const (
defaultResponseBodyCapacity = 1024 * 10 // 10 KB for response body
defaultResponseErrorCapacity = 1024 // 1 KB for error messages
)
// callHostHTTPFetch provides a Go-friendly interface to the http_fetch host function.
func callHostHTTPFetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
log.Printf("[MCP] Debug: WASM Fetch (Host Call): Method=%s, URL=%s, Timeout=%v", method, url, timeout)
// --- Prepare Input Pointers ---
urlPtr, urlLen := stringToPtr(url)
methodPtr, methodLen := stringToPtr(method)
bodyPtr, bodyLen := bytesToPtr(requestBody)
timeoutMillis := uint32(timeout.Milliseconds())
if timeoutMillis <= 0 {
timeoutMillis = 30000 // Default 30 seconds if 0 or negative
}
if timeout == 0 {
// Handle case where context might already be cancelled
select {
case <-ctx.Done():
log.Printf("[MCP] Debug: WASM Fetch context cancelled before host call: %v", ctx.Err())
return 0, nil, ctx.Err()
default:
}
}
// --- Prepare Output Buffers and Pointers ---
resultBodyBuffer := make([]byte, defaultResponseBodyCapacity)
resultErrorBuffer := make([]byte, defaultResponseErrorCapacity)
resultStatus := uint32(0)
resultBodyLen := uint32(0)
resultErrorLen := uint32(0)
resultStatusPtr := &resultStatus
resultBodyPtr, resultBodyCapacity := bytesToPtr(resultBodyBuffer)
resultBodyLenPtr := &resultBodyLen
resultErrorPtr, resultErrorCapacity := bytesToPtr(resultErrorBuffer)
resultErrorLenPtr := &resultErrorLen
// --- Call the Host Function ---
log.Printf("[MCP] Debug: WASM Fetch calling host function http_fetch...")
hostReturnCode := http_fetch(
urlPtr, urlLen,
methodPtr, methodLen,
bodyPtr, bodyLen,
timeoutMillis,
uint32(uintptr(unsafe.Pointer(resultStatusPtr))),
resultBodyPtr, resultBodyCapacity, uint32(uintptr(unsafe.Pointer(resultBodyLenPtr))),
resultErrorPtr, resultErrorCapacity, uint32(uintptr(unsafe.Pointer(resultErrorLenPtr))),
)
log.Printf("[MCP] Debug: WASM Fetch host function returned code: %d", hostReturnCode)
// --- Process Results ---
if hostReturnCode != 0 {
err = errors.New("host function http_fetch failed internally")
log.Printf("[MCP] Error: WASM Fetch host function failed: %v", err)
return 0, nil, err
}
statusCode = int(resultStatus)
log.Printf("[MCP] Debug: WASM Fetch received status code from host: %d", statusCode)
if resultErrorLen > 0 {
actualErrorLen := min(resultErrorLen, resultErrorCapacity)
errMsg := string(resultErrorBuffer[:actualErrorLen])
err = errors.New(errMsg)
log.Printf("[MCP] Error: WASM Fetch received error from host: %s", errMsg)
return statusCode, nil, err
}
if resultBodyLen > 0 {
actualBodyLen := min(resultBodyLen, resultBodyCapacity)
responseBody = make([]byte, actualBodyLen)
copy(responseBody, resultBodyBuffer[:actualBodyLen])
log.Printf("[MCP] Debug: WASM Fetch received %d bytes from host body (reported size: %d)", actualBodyLen, resultBodyLen)
if resultBodyLen > resultBodyCapacity {
err = fmt.Errorf("response body truncated: received %d bytes, but actual size was %d", actualBodyLen, resultBodyLen)
log.Printf("[MCP] Warn: WASM Fetch %v", err)
return statusCode, responseBody, err // Return truncated body with error
}
log.Printf("[MCP] Debug: WASM Fetch completed successfully.")
return statusCode, responseBody, nil
}
log.Printf("[MCP] Debug: WASM Fetch completed successfully (no body, no error).")
return statusCode, nil, nil
}
// --- Pointer Helper Functions --- (Copied from user prompt)
func stringToPtr(s string) (ptr uint32, length uint32) {
if len(s) == 0 {
return 0, 0
}
// Use unsafe.StringData for potentially safer pointer access in modern Go
// Needs Go 1.20+
// return uint32(uintptr(unsafe.Pointer(unsafe.StringData(s)))), uint32(len(s))
// Fallback to slice conversion for broader compatibility / if StringData isn't available
buf := []byte(s)
return bytesToPtr(buf)
}
func bytesToPtr(b []byte) (ptr uint32, length uint32) {
if len(b) == 0 {
return 0, 0
}
// Use unsafe.SliceData for potentially safer pointer access in modern Go
// Needs Go 1.20+
// return uint32(uintptr(unsafe.Pointer(unsafe.SliceData(b)))), uint32(len(b))
// Fallback for broader compatibility
return uint32(uintptr(unsafe.Pointer(&b[0]))), uint32(len(b))
}
func min(a, b uint32) uint32 {
if a < b {
return a
}
return b
}
// --- WASM Fetcher Implementation ---
type wasmFetcher struct{}
// Ensure wasmFetcher implements Fetcher
var _ Fetcher = (*wasmFetcher)(nil)
// NewFetcher creates the WASM host function fetcher.
func NewFetcher() Fetcher {
log.Println("[MCP] Debug: Using WASM host fetcher")
return &wasmFetcher{}
}
func (wf *wasmFetcher) Fetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
// Directly call the wrapper which now contains logging
return callHostHTTPFetch(ctx, method, url, requestBody, timeout)
}

View File

@@ -0,0 +1,19 @@
module mcp-server
go 1.24.2
require github.com/metoro-io/mcp-golang v0.11.0
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/invopop/jsonschema v0.12.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,34 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
github.com/metoro-io/mcp-golang v0.11.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,289 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/url"
"os"
mcp_golang "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
)
type Content struct {
Title string `json:"title" jsonschema:"required,description=The title to submit"`
Description *string `json:"description" jsonschema:"description=The description to submit"`
}
type MyFunctionsArguments struct {
Submitter string `json:"submitter" jsonschema:"required,description=The name of the thing calling this tool (openai, google, claude, etc)"`
Content Content `json:"content" jsonschema:"required,description=The content of the message"`
}
type ArtistBiography struct {
ID string `json:"id" jsonschema:"required,description=The id of the artist"`
Name string `json:"name" jsonschema:"required,description=The name of the artist"`
MBID string `json:"mbid" jsonschema:"description=The mbid of the artist"`
}
type ArtistURLArgs struct {
ID string `json:"id" jsonschema:"required,description=The id of the artist"`
Name string `json:"name" jsonschema:"required,description=The name of the artist"`
MBID string `json:"mbid" jsonschema:"description=The mbid of the artist"`
}
func main() {
log.Println("[MCP] Starting mcp-server...")
done := make(chan struct{})
// Create the appropriate fetcher (native or WASM based on build tags)
log.Printf("[MCP] Debug: Creating fetcher...")
fetcher := NewFetcher()
log.Printf("[MCP] Debug: Fetcher created successfully.")
// --- Command Line Flag Handling ---
nameFlag := flag.String("name", "", "Artist name to query directly")
mbidFlag := flag.String("mbid", "", "Artist MBID to query directly")
flag.Parse()
if *nameFlag != "" || *mbidFlag != "" {
log.Printf("[MCP] Debug: Running tools directly via CLI flags (Name: '%s', MBID: '%s')", *nameFlag, *mbidFlag)
fmt.Println("--- Running Tools Directly ---")
// Call getArtistBiography
fmt.Printf("Calling get_artist_biography (Name: '%s', MBID: '%s')...\n", *nameFlag, *mbidFlag)
if *mbidFlag == "" && *nameFlag == "" {
fmt.Println(" Error: --mbid or --name is required for get_artist_biography")
} else {
// Use context.Background for CLI calls
log.Printf("[MCP] Debug: CLI calling getArtistBiography...")
bio, bioErr := getArtistBiography(fetcher, context.Background(), "cli", *nameFlag, *mbidFlag)
if bioErr != nil {
fmt.Printf(" Error: %v\n", bioErr)
log.Printf("[MCP] Error: CLI getArtistBiography failed: %v", bioErr)
} else {
fmt.Printf(" Result: %s\n", bio)
log.Printf("[MCP] Debug: CLI getArtistBiography succeeded.")
}
}
// Call getArtistURL
fmt.Printf("Calling get_artist_url (Name: '%s', MBID: '%s')...\n", *nameFlag, *mbidFlag)
if *mbidFlag == "" && *nameFlag == "" {
fmt.Println(" Error: --mbid or --name is required for get_artist_url")
} else {
log.Printf("[MCP] Debug: CLI calling getArtistURL...")
urlResult, urlErr := getArtistURL(fetcher, context.Background(), "cli", *nameFlag, *mbidFlag)
if urlErr != nil {
fmt.Printf(" Error: %v\n", urlErr)
log.Printf("[MCP] Error: CLI getArtistURL failed: %v", urlErr)
} else {
fmt.Printf(" Result: %s\n", urlResult)
log.Printf("[MCP] Debug: CLI getArtistURL succeeded.")
}
}
fmt.Println("-----------------------------")
log.Printf("[MCP] Debug: CLI execution finished.")
return // Exit after direct execution
}
// --- End Command Line Flag Handling ---
log.Printf("[MCP] Debug: Initializing MCP server...")
server := mcp_golang.NewServer(stdio.NewStdioServerTransport())
log.Printf("[MCP] Debug: Registering tool 'hello'...")
err := server.RegisterTool("hello", "Say hello to a person", func(arguments MyFunctionsArguments) (*mcp_golang.ToolResponse, error) {
log.Printf("[MCP] Debug: Tool 'hello' called with args: %+v", arguments)
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("Hello, %server!", arguments.Submitter))), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register tool 'hello': %v", err)
}
log.Printf("[MCP] Debug: Registering tool 'get_artist_biography'...")
err = server.RegisterTool("get_artist_biography", "Get the biography of an artist", func(arguments ArtistBiography) (*mcp_golang.ToolResponse, error) {
log.Printf("[MCP] Debug: Tool 'get_artist_biography' called with args: %+v", arguments)
// Using background context in handlers as request context isn't passed through MCP library currently
bio, err := getArtistBiography(fetcher, context.Background(), arguments.ID, arguments.Name, arguments.MBID)
if err != nil {
log.Printf("[MCP] Error: getArtistBiography handler failed: %v", err)
return nil, fmt.Errorf("handler returned an error: %w", err) // Return structured error
}
log.Printf("[MCP] Debug: Tool 'get_artist_biography' succeeded.")
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(bio)), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register tool 'get_artist_biography': %v", err)
}
log.Printf("[MCP] Debug: Registering tool 'get_artist_url'...")
err = server.RegisterTool("get_artist_url", "Get the artist's specific Wikipedia URL via MBID, or a search URL using name as fallback", func(arguments ArtistURLArgs) (*mcp_golang.ToolResponse, error) {
log.Printf("[MCP] Debug: Tool 'get_artist_url' called with args: %+v", arguments)
urlResult, err := getArtistURL(fetcher, context.Background(), arguments.ID, arguments.Name, arguments.MBID)
if err != nil {
log.Printf("[MCP] Error: getArtistURL handler failed: %v", err)
return nil, fmt.Errorf("handler returned an error: %w", err)
}
log.Printf("[MCP] Debug: Tool 'get_artist_url' succeeded.")
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(urlResult)), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register tool 'get_artist_url': %v", err)
}
log.Printf("[MCP] Debug: Registering prompt 'prompt_test'...")
err = server.RegisterPrompt("prompt_test", "This is a test prompt", func(arguments Content) (*mcp_golang.PromptResponse, error) {
log.Printf("[MCP] Debug: Prompt 'prompt_test' called with args: %+v", arguments)
return mcp_golang.NewPromptResponse("description", mcp_golang.NewPromptMessage(mcp_golang.NewTextContent(fmt.Sprintf("Hello, %server!", arguments.Title)), mcp_golang.RoleUser)), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register prompt 'prompt_test': %v", err)
}
log.Printf("[MCP] Debug: Registering resource 'test://resource'...")
err = server.RegisterResource("test://resource", "resource_test", "This is a test resource", "application/json", func() (*mcp_golang.ResourceResponse, error) {
log.Printf("[MCP] Debug: Resource 'test://resource' called")
return mcp_golang.NewResourceResponse(mcp_golang.NewTextEmbeddedResource("test://resource", "This is a test resource", "application/json")), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register resource 'test://resource': %v", err)
}
log.Printf("[MCP] Debug: Registering resource 'file://app_logs'...")
err = server.RegisterResource("file://app_logs", "app_logs", "The app logs", "text/plain", func() (*mcp_golang.ResourceResponse, error) {
log.Printf("[MCP] Debug: Resource 'file://app_logs' called")
return mcp_golang.NewResourceResponse(mcp_golang.NewTextEmbeddedResource("file://app_logs", "This is a test resource", "text/plain")), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register resource 'file://app_logs': %v", err)
}
log.Println("[MCP] MCP server initialized and starting to serve...")
err = server.Serve()
if err != nil {
log.Fatalf("[MCP] Fatal: Server exited with error: %v", err)
}
log.Println("[MCP] Server exited cleanly.")
<-done // Keep running until interrupted (though server.Serve() is blocking)
}
func getArtistBiography(fetcher Fetcher, ctx context.Context, id, name, mbid string) (string, error) {
log.Printf("[MCP] Debug: getArtistBiography called (id: %s, name: %s, mbid: %s)", id, name, mbid)
if mbid == "" {
fmt.Fprintf(os.Stderr, "MBID not provided, attempting DBpedia lookup by name: %s\n", name)
log.Printf("[MCP] Debug: MBID not provided, attempting DBpedia lookup by name: %s", name)
} else {
// 1. Attempt Wikidata MBID lookup first
log.Printf("[MCP] Debug: Attempting Wikidata URL lookup for MBID: %s", mbid)
wikiURL, err := GetArtistWikipediaURL(fetcher, ctx, mbid)
if err == nil {
// 1a. Found Wikidata URL, now fetch from Wikipedia API
log.Printf("[MCP] Debug: Found Wikidata URL '%s', fetching bio from Wikipedia API...", wikiURL)
bio, errBio := GetBioFromWikipediaAPI(fetcher, ctx, wikiURL)
if errBio == nil {
log.Printf("[MCP] Debug: Successfully fetched bio from Wikipedia API for '%s'.", name)
return bio, nil // Success via Wikidata/Wikipedia!
} else {
// Failed to get bio even though URL was found
log.Printf("[MCP] Error: Found Wikipedia URL (%s) via MBID %s, but failed to fetch bio: %v", wikiURL, mbid, errBio)
fmt.Fprintf(os.Stderr, "Found Wikipedia URL (%s) via MBID %s, but failed to fetch bio: %v\n", wikiURL, mbid, errBio)
// Fall through to try DBpedia by name as a last resort?
// Let's fall through for now.
}
} else if !errors.Is(err, ErrNotFound) {
// Wikidata lookup failed for a reason other than not found (e.g., network)
log.Printf("[MCP] Error: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v", mbid, err)
fmt.Fprintf(os.Stderr, "Wikidata URL lookup failed for MBID %s (non-NotFound error): %v\n", mbid, err)
// Don't proceed to DBpedia name lookup if Wikidata had a technical failure
return "", fmt.Errorf("Wikidata lookup failed: %w", err)
} else {
// Wikidata lookup returned ErrNotFound for MBID
log.Printf("[MCP] Debug: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s", mbid, name)
fmt.Fprintf(os.Stderr, "MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s\n", mbid, name)
}
}
// 2. Attempt DBpedia lookup by name (if MBID was missing or failed with ErrNotFound)
if name == "" {
log.Printf("[MCP] Error: Cannot find artist bio: MBID lookup failed/missing, and no name provided.")
return "", fmt.Errorf("cannot find artist: MBID lookup failed or MBID not provided, and no name provided for DBpedia fallback")
}
log.Printf("[MCP] Debug: Attempting DBpedia bio lookup by name: %s", name)
dbpediaBio, errDb := GetArtistBioFromDBpedia(fetcher, ctx, name)
if errDb == nil {
log.Printf("[MCP] Debug: Successfully fetched bio from DBpedia for '%s'.", name)
return dbpediaBio, nil // Success via DBpedia!
}
// 3. If both Wikidata (MBID) and DBpedia (Name) failed
if errors.Is(errDb, ErrNotFound) {
log.Printf("[MCP] Error: Artist '%s' (MBID: %s) not found via Wikidata or DBpedia name lookup.", name, mbid)
return "", fmt.Errorf("artist '%s' (MBID: %s) not found via Wikidata MBID or DBpedia Name lookup", name, mbid)
}
// Return DBpedia's error if it wasn't ErrNotFound
log.Printf("[MCP] Error: DBpedia lookup failed for name '%s': %v", name, errDb)
return "", fmt.Errorf("DBpedia lookup failed for name '%s': %w", name, errDb)
}
// getArtistURL attempts to find the specific Wikipedia URL using MBID (via Wikidata),
// then by Name (via DBpedia), falling back to a search URL using name.
func getArtistURL(fetcher Fetcher, ctx context.Context, id, name, mbid string) (string, error) {
log.Printf("[MCP] Debug: getArtistURL called (id: %s, name: %s, mbid: %s)", id, name, mbid)
if mbid == "" {
fmt.Fprintf(os.Stderr, "getArtistURL: MBID not provided, attempting DBpedia lookup by name: %s\n", name)
log.Printf("[MCP] Debug: getArtistURL: MBID not provided, attempting DBpedia lookup by name: %s", name)
} else {
// Try to get the specific URL from Wikidata using MBID
log.Printf("[MCP] Debug: getArtistURL: Attempting Wikidata URL lookup for MBID: %s", mbid)
wikiURL, err := GetArtistWikipediaURL(fetcher, ctx, mbid)
if err == nil && wikiURL != "" {
log.Printf("[MCP] Debug: getArtistURL: Found specific URL '%s' via Wikidata MBID.", wikiURL)
return wikiURL, nil // Found specific URL via MBID
}
// Log error if Wikidata lookup failed for reasons other than not found
if err != nil && !errors.Is(err, ErrNotFound) {
log.Printf("[MCP] Error: getArtistURL: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v", mbid, err)
fmt.Fprintf(os.Stderr, "getArtistURL: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v\n", mbid, err)
// Fall through to try DBpedia if name is available
} else if errors.Is(err, ErrNotFound) {
log.Printf("[MCP] Debug: getArtistURL: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s", mbid, name)
fmt.Fprintf(os.Stderr, "getArtistURL: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s\n", mbid, name)
}
}
// Fallback 1: Try DBpedia lookup by name
if name != "" {
log.Printf("[MCP] Debug: getArtistURL: Attempting DBpedia URL lookup by name: %s", name)
dbpediaWikiURL, errDb := GetArtistWikipediaURLFromDBpedia(fetcher, ctx, name)
if errDb == nil && dbpediaWikiURL != "" {
log.Printf("[MCP] Debug: getArtistURL: Found specific URL '%s' via DBpedia Name lookup.", dbpediaWikiURL)
return dbpediaWikiURL, nil // Found specific URL via DBpedia Name lookup
}
// Log error if DBpedia lookup failed for reasons other than not found
if errDb != nil && !errors.Is(errDb, ErrNotFound) {
log.Printf("[MCP] Error: getArtistURL: DBpedia URL lookup failed for name '%s' (non-NotFound error): %v", name, errDb)
fmt.Fprintf(os.Stderr, "getArtistURL: DBpedia URL lookup failed for name '%s' (non-NotFound error): %v\n", name, errDb)
// Fall through to search URL fallback
} else if errors.Is(errDb, ErrNotFound) {
log.Printf("[MCP] Debug: getArtistURL: Name '%s' not found on DBpedia, attempting search fallback", name)
fmt.Fprintf(os.Stderr, "getArtistURL: Name '%s' not found on DBpedia, attempting search fallback\n", name)
}
}
// Fallback 2: Generate a search URL if name is provided
if name != "" {
searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(name))
log.Printf("[MCP] Debug: getArtistURL: Falling back to search URL: %s", searchURL)
fmt.Fprintf(os.Stderr, "getArtistURL: Falling back to search URL: %s\n", searchURL)
return searchURL, nil
}
// Final error: MBID lookup failed (or no MBID given) AND no name provided for fallback
log.Printf("[MCP] Error: getArtistURL: Cannot generate Wikipedia URL: Lookups failed and no name provided.")
return "", fmt.Errorf("cannot generate Wikipedia URL: Wikidata/DBpedia lookups failed and no artist name provided for search fallback")
}

View File

@@ -0,0 +1,205 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"time"
)
const wikidataEndpoint = "https://query.wikidata.org/sparql"
// ErrNotFound indicates a specific item (like an artist or URL) was not found on Wikidata.
var ErrNotFound = errors.New("item not found on Wikidata")
// Wikidata SPARQL query result structures
type SparqlResult struct {
Results SparqlBindings `json:"results"`
}
type SparqlBindings struct {
Bindings []map[string]SparqlValue `json:"bindings"`
}
type SparqlValue struct {
Type string `json:"type"`
Value string `json:"value"`
Lang string `json:"xml:lang,omitempty"` // Handle language tags like "en"
}
// GetArtistBioFromWikidata queries Wikidata for an artist's description using their MBID.
// NOTE: This function is currently UNUSED as the main logic prefers Wikipedia/DBpedia.
func GetArtistBioFromWikidata(client *http.Client, mbid string) (string, error) {
log.Printf("[MCP] Debug: GetArtistBioFromWikidata called for MBID: %s", mbid)
if mbid == "" {
log.Printf("[MCP] Error: GetArtistBioFromWikidata requires an MBID.")
return "", fmt.Errorf("MBID is required to query Wikidata")
}
// SPARQL query to find the English description for an entity with a specific MusicBrainz ID
sparqlQuery := fmt.Sprintf(`
SELECT ?artistDescription WHERE {
?artist wdt:P434 "%s" . # P434 is the property for MusicBrainz artist ID
OPTIONAL {
?artist schema:description ?artistDescription .
FILTER(LANG(?artistDescription) = "en")
}
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}
LIMIT 1`, mbid)
// Prepare the HTTP request
queryValues := url.Values{}
queryValues.Set("query", sparqlQuery)
queryValues.Set("format", "json")
reqURL := fmt.Sprintf("%s?%s", wikidataEndpoint, queryValues.Encode())
log.Printf("[MCP] Debug: Wikidata Bio Request URL: %s", reqURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
log.Printf("[MCP] Error: Failed to create Wikidata bio request: %v", err)
return "", fmt.Errorf("failed to create Wikidata request: %w", err)
}
req.Header.Set("Accept", "application/sparql-results+json")
req.Header.Set("User-Agent", "MCPGoServerExample/0.1 (https://example.com/contact)") // Good practice to identify your client
// Execute the request
log.Printf("[MCP] Debug: Executing Wikidata bio request...")
resp, err := client.Do(req)
if err != nil {
log.Printf("[MCP] Error: Failed to execute Wikidata bio request: %v", err)
return "", fmt.Errorf("failed to execute Wikidata request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Attempt to read body for more error info, but don't fail if it doesn't work
bodyBytes, readErr := io.ReadAll(resp.Body)
errorMsg := "Could not read error body"
if readErr == nil {
errorMsg = string(bodyBytes)
}
log.Printf("[MCP] Error: Wikidata bio query failed with status %d: %s", resp.StatusCode, errorMsg)
return "", fmt.Errorf("Wikidata query failed with status %d: %s", resp.StatusCode, errorMsg)
}
log.Printf("[MCP] Debug: Wikidata bio query successful (status %d).", resp.StatusCode)
// Parse the response
var result SparqlResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.Printf("[MCP] Error: Failed to decode Wikidata bio response: %v", err)
return "", fmt.Errorf("failed to decode Wikidata response: %w", err)
}
// Extract the description
if len(result.Results.Bindings) > 0 {
if descriptionVal, ok := result.Results.Bindings[0]["artistDescription"]; ok {
log.Printf("[MCP] Debug: Found description for MBID %s", mbid)
return descriptionVal.Value, nil
}
}
log.Printf("[MCP] Warn: No English description found on Wikidata for MBID %s", mbid)
return "", fmt.Errorf("no English description found on Wikidata for MBID %s", mbid)
}
// GetArtistWikipediaURL queries Wikidata for an artist's English Wikipedia page URL using MBID.
// It tries searching by MBID first, then falls back to searching by name.
func GetArtistWikipediaURL(fetcher Fetcher, ctx context.Context, mbid string) (string, error) {
log.Printf("[MCP] Debug: GetArtistWikipediaURL called for MBID: %s", mbid)
// 1. Try finding by MBID
if mbid == "" {
log.Printf("[MCP] Error: GetArtistWikipediaURL requires an MBID.")
return "", fmt.Errorf("MBID is required to find Wikipedia URL on Wikidata")
} else {
// SPARQL query to find the enwiki URL for an entity with a specific MusicBrainz ID
sparqlQuery := fmt.Sprintf(`
SELECT ?article WHERE {
?artist wdt:P434 "%s" . # P434 is MusicBrainz artist ID
?article schema:about ?artist ;
schema:isPartOf <https://en.wikipedia.org/> .
}
LIMIT 1`, mbid)
log.Printf("[MCP] Debug: Executing Wikidata URL query for MBID: %s", mbid)
foundURL, err := executeWikidataURLQuery(fetcher, ctx, sparqlQuery)
if err == nil && foundURL != "" {
log.Printf("[MCP] Debug: Found Wikipedia URL '%s' via MBID %s", foundURL, mbid)
return foundURL, nil // Found via MBID
}
// Use the specific ErrNotFound
if errors.Is(err, ErrNotFound) {
log.Printf("[MCP] Debug: MBID %s not found on Wikidata for URL lookup.", mbid)
return "", ErrNotFound // Explicitly return ErrNotFound
}
// Log other errors
if err != nil {
log.Printf("[MCP] Error: Wikidata URL lookup via MBID %s failed: %v", mbid, err)
fmt.Fprintf(os.Stderr, "Wikidata URL lookup via MBID %s failed: %v\n", mbid, err)
return "", fmt.Errorf("Wikidata URL lookup via MBID failed: %w", err)
}
}
// Should ideally not be reached if MBID is required and lookup failed or was not found
log.Printf("[MCP] Warn: Reached end of GetArtistWikipediaURL unexpectedly for MBID %s", mbid)
return "", ErrNotFound // Return ErrNotFound if somehow reached
}
// executeWikidataURLQuery is a helper to run SPARQL and extract the first bound URL for '?article'.
func executeWikidataURLQuery(fetcher Fetcher, ctx context.Context, sparqlQuery string) (string, error) {
log.Printf("[MCP] Debug: executeWikidataURLQuery called.")
queryValues := url.Values{}
queryValues.Set("query", sparqlQuery)
queryValues.Set("format", "json")
reqURL := fmt.Sprintf("%s?%s", wikidataEndpoint, queryValues.Encode())
log.Printf("[MCP] Debug: Wikidata Sparql Request URL: %s", reqURL)
// Directly use the fetcher
// Note: Headers (Accept, User-Agent) are now handled by the Fetcher implementation
// The WASM fetcher currently doesn't support setting them via the host func interface.
// Timeout is handled via context for native, and passed to host func for WASM.
// Let's use a default timeout here if not provided via context (e.g., 15s)
// TODO: Consider making timeout configurable or passed down
timeout := 15 * time.Second
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
log.Printf("[MCP] Debug: Fetching from Wikidata with timeout: %v", timeout)
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
if err != nil {
log.Printf("[MCP] Error: Fetcher failed for Wikidata SPARQL request: %v", err)
return "", fmt.Errorf("failed to execute Wikidata request: %w", err)
}
// Check status code. Fetcher interface implies body might be returned even on error.
if statusCode != http.StatusOK {
log.Printf("[MCP] Error: Wikidata SPARQL query failed with status %d: %s", statusCode, string(bodyBytes))
return "", fmt.Errorf("Wikidata query failed with status %d: %s", statusCode, string(bodyBytes))
}
log.Printf("[MCP] Debug: Wikidata SPARQL query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
var result SparqlResult
if err := json.Unmarshal(bodyBytes, &result); err != nil { // Use Unmarshal for byte slice
log.Printf("[MCP] Error: Failed to decode Wikidata SPARQL response: %v", err)
return "", fmt.Errorf("failed to decode Wikidata response: %w", err)
}
if len(result.Results.Bindings) > 0 {
if articleVal, ok := result.Results.Bindings[0]["article"]; ok {
log.Printf("[MCP] Debug: Found Wikidata article URL: %s", articleVal.Value)
return articleVal.Value, nil
}
}
log.Printf("[MCP] Debug: No Wikidata article URL found in SPARQL response.")
return "", ErrNotFound
}

View File

@@ -0,0 +1,121 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
)
const mediaWikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
// Structures for parsing MediaWiki API response (query extracts)
type MediaWikiQueryResult struct {
Query MediaWikiQuery `json:"query"`
}
type MediaWikiQuery struct {
Pages map[string]MediaWikiPage `json:"pages"`
}
type MediaWikiPage struct {
PageID int `json:"pageid"`
Ns int `json:"ns"`
Title string `json:"title"`
Extract string `json:"extract"`
}
// Default timeout for Wikipedia API requests
const defaultWikipediaTimeout = 15 * time.Second
// GetBioFromWikipediaAPI fetches the introductory text of a Wikipedia page.
func GetBioFromWikipediaAPI(fetcher Fetcher, ctx context.Context, wikipediaURL string) (string, error) {
log.Printf("[MCP] Debug: GetBioFromWikipediaAPI called for URL: %s", wikipediaURL)
pageTitle, err := extractPageTitleFromURL(wikipediaURL)
if err != nil {
log.Printf("[MCP] Error: Could not extract title from Wikipedia URL '%s': %v", wikipediaURL, err)
return "", fmt.Errorf("could not extract title from Wikipedia URL %s: %w", wikipediaURL, err)
}
log.Printf("[MCP] Debug: Extracted Wikipedia page title: %s", pageTitle)
// Prepare API request parameters
apiParams := url.Values{}
apiParams.Set("action", "query")
apiParams.Set("format", "json")
apiParams.Set("prop", "extracts") // Request page extracts
apiParams.Set("exintro", "true") // Get only the intro section
apiParams.Set("explaintext", "true") // Get plain text instead of HTML
apiParams.Set("titles", pageTitle) // Specify the page title
apiParams.Set("redirects", "1") // Follow redirects
reqURL := fmt.Sprintf("%s?%s", mediaWikiAPIEndpoint, apiParams.Encode())
log.Printf("[MCP] Debug: MediaWiki API Request URL: %s", reqURL)
timeout := defaultWikipediaTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
log.Printf("[MCP] Debug: Fetching from MediaWiki with timeout: %v", timeout)
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
if err != nil {
log.Printf("[MCP] Error: Fetcher failed for MediaWiki request (title: '%s'): %v", pageTitle, err)
return "", fmt.Errorf("failed to execute MediaWiki request for title '%s': %w", pageTitle, err)
}
if statusCode != http.StatusOK {
log.Printf("[MCP] Error: MediaWiki query for '%s' failed with status %d: %s", pageTitle, statusCode, string(bodyBytes))
return "", fmt.Errorf("MediaWiki query for '%s' failed with status %d: %s", pageTitle, statusCode, string(bodyBytes))
}
log.Printf("[MCP] Debug: MediaWiki query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
// Parse the response
var result MediaWikiQueryResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
log.Printf("[MCP] Error: Failed to decode MediaWiki response for '%s': %v", pageTitle, err)
return "", fmt.Errorf("failed to decode MediaWiki response for '%s': %w", pageTitle, err)
}
// Extract the text - MediaWiki API returns pages keyed by page ID
for pageID, page := range result.Query.Pages {
log.Printf("[MCP] Debug: Processing MediaWiki page ID: %s, Title: %s", pageID, page.Title)
if page.Extract != "" {
// Often includes a newline at the end, trim it
log.Printf("[MCP] Debug: Found extract for '%s'. Length: %d", pageTitle, len(page.Extract))
return strings.TrimSpace(page.Extract), nil
}
}
log.Printf("[MCP] Warn: No extract found in MediaWiki response for title '%s'", pageTitle)
return "", fmt.Errorf("no extract found in MediaWiki response for title '%s' (page might not exist or be empty)", pageTitle)
}
// extractPageTitleFromURL attempts to get the page title from a standard Wikipedia URL.
// Example: https://en.wikipedia.org/wiki/The_Beatles -> The_Beatles
func extractPageTitleFromURL(wikiURL string) (string, error) {
parsedURL, err := url.Parse(wikiURL)
if err != nil {
return "", err
}
if parsedURL.Host != "en.wikipedia.org" {
return "", fmt.Errorf("URL host is not en.wikipedia.org: %s", parsedURL.Host)
}
pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/")
if len(pathParts) < 2 || pathParts[0] != "wiki" {
return "", fmt.Errorf("URL path does not match /wiki/<title> format: %s", parsedURL.Path)
}
title := pathParts[1]
if title == "" {
return "", fmt.Errorf("extracted title is empty")
}
// URL Decode the title (e.g., %27 -> ')
decodedTitle, err := url.PathUnescape(title)
if err != nil {
return "", fmt.Errorf("failed to decode title '%s': %w", title, err)
}
return decodedTitle, nil
}

View File

@@ -0,0 +1,207 @@
package mcp
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"time"
mcp "github.com/metoro-io/mcp-golang"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// Constants used by the MCP agent
const (
McpAgentName = "mcp"
initializationTimeout = 5 * time.Second
// McpServerPath defines the location of the MCP server executable or WASM module.
// McpServerPath = "./core/agents/mcp/mcp-server/mcp-server"
McpServerPath = "./core/agents/mcp/mcp-server/mcp-server.wasm"
McpToolNameGetBio = "get_artist_biography"
McpToolNameGetURL = "get_artist_url"
)
// mcpClient interface matching the methods used from mcp.Client.
type mcpClient interface {
Initialize(ctx context.Context) (*mcp.InitializeResponse, error)
CallTool(ctx context.Context, toolName string, args any) (*mcp.ToolResponse, error)
}
// mcpImplementation defines the common interface for both native and WASM MCP agents.
// This allows the main MCPAgent to delegate calls without knowing the underlying type.
type mcpImplementation interface {
Close() error // For cleaning up resources associated with this specific implementation.
// callMCPTool is the core method implemented differently by native/wasm
callMCPTool(ctx context.Context, toolName string, args any) (string, error)
}
// MCPAgent is the public-facing agent registered with Navidrome.
// It acts as a wrapper around the actual implementation (native or WASM).
type MCPAgent struct {
// No mutex needed here if impl is set once at construction
// and the implementation handles its own internal state synchronization.
impl mcpImplementation
// Shared Wazero resources (runtime, cache) are managed externally
// and closed separately, likely during application shutdown.
}
// mcpConstructor creates the appropriate MCP implementation (native or WASM)
// and wraps it in the MCPAgent.
func mcpConstructor(ds model.DataStore) agents.Interface {
if _, err := os.Stat(McpServerPath); os.IsNotExist(err) {
log.Warn("MCP server executable/WASM not found, disabling agent", "path", McpServerPath, "error", err)
return nil
}
var agentImpl mcpImplementation
var err error
if strings.HasSuffix(McpServerPath, ".wasm") {
log.Info("Configuring MCP agent for WASM execution", "path", McpServerPath)
ctx := context.Background()
// Setup Shared Wazero Resources
var cache wazero.CompilationCache
cacheDir := filepath.Join(conf.Server.DataFolder, "cache", "wazero")
if errMkdir := os.MkdirAll(cacheDir, 0755); errMkdir != nil {
log.Error(ctx, "Failed to create Wazero cache directory, WASM caching disabled", "path", cacheDir, "error", errMkdir)
} else {
cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
if err != nil {
log.Error(ctx, "Failed to create Wazero compilation cache, WASM caching disabled", "path", cacheDir, "error", err)
cache = nil
}
}
runtimeConfig := wazero.NewRuntimeConfig()
if cache != nil {
runtimeConfig = runtimeConfig.WithCompilationCache(cache)
}
runtime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
if err = registerHostFunctions(ctx, runtime); err != nil {
_ = runtime.Close(ctx)
if cache != nil {
_ = cache.Close(ctx)
}
return nil // Fatal error: Host functions required
}
if _, err = wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
log.Error(ctx, "Failed to instantiate WASI on shared Wazero runtime, MCP WASM agent disabled", "error", err)
_ = runtime.Close(ctx)
if cache != nil {
_ = cache.Close(ctx)
}
return nil // Fatal error: WASI required
}
// Compile the module
log.Debug(ctx, "Pre-compiling WASM module...", "path", McpServerPath)
wasmBytes, err := os.ReadFile(McpServerPath)
if err != nil {
log.Error(ctx, "Failed to read WASM module file, disabling agent", "path", McpServerPath, "error", err)
_ = runtime.Close(ctx)
if cache != nil {
_ = cache.Close(ctx)
}
return nil
}
compiledModule, err := runtime.CompileModule(ctx, wasmBytes)
if err != nil {
log.Error(ctx, "Failed to pre-compile WASM module, disabling agent", "path", McpServerPath, "error", err)
_ = runtime.Close(ctx)
if cache != nil {
_ = cache.Close(ctx)
}
return nil
}
agentImpl = newMCPWasm(runtime, cache, compiledModule)
log.Info(ctx, "Shared Wazero runtime, WASI, cache, host functions initialized, and module pre-compiled for MCP agent")
} else {
log.Info("Configuring MCP agent for native execution", "path", McpServerPath)
agentImpl = newMCPNative()
}
log.Info("MCP Agent implementation created successfully")
return &MCPAgent{impl: agentImpl}
}
// NewAgentForTesting is a constructor specifically for tests.
// It creates the appropriate implementation based on McpServerPath
// and injects a mock mcpClient into its ClientOverride field.
func NewAgentForTesting(mockClient mcpClient) agents.Interface {
// We need to replicate the logic from mcpConstructor to determine
// the implementation type, but without actually starting processes.
var agentImpl mcpImplementation
if strings.HasSuffix(McpServerPath, ".wasm") {
// For WASM testing, we might not need the full runtime setup,
// just the struct. Pass nil for shared resources for now.
// We rely on the mockClient being used before any real WASM interaction.
wasmImpl := newMCPWasm(nil, nil, nil)
wasmImpl.ClientOverride = mockClient
agentImpl = wasmImpl
} else {
nativeImpl := newMCPNative()
nativeImpl.ClientOverride = mockClient
agentImpl = nativeImpl
}
return &MCPAgent{impl: agentImpl}
}
func (a *MCPAgent) AgentName() string {
return McpAgentName
}
func (a *MCPAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
if a.impl == nil {
return "", errors.New("MCP agent implementation is nil")
}
// Construct args and call the implementation's specific tool caller
args := ArtistArgs{ID: id, Name: name, Mbid: mbid}
return a.impl.callMCPTool(ctx, McpToolNameGetBio, args)
}
func (a *MCPAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
if a.impl == nil {
return "", errors.New("MCP agent implementation is nil")
}
// Construct args and call the implementation's specific tool caller
args := ArtistArgs{ID: id, Name: name, Mbid: mbid}
return a.impl.callMCPTool(ctx, McpToolNameGetURL, args)
}
// Note: A Close method on MCPAgent itself isn't part of agents.Interface.
// Cleanup of the specific implementation happens via impl.Close().
// Cleanup of shared Wazero resources needs separate handling (e.g., on app shutdown).
// ArtistArgs defines the structure for MCP tool arguments requiring artist info.
type ArtistArgs struct {
ID string `json:"id"`
Name string `json:"name"`
Mbid string `json:"mbid,omitempty"`
}
var _ agents.ArtistBiographyRetriever = (*MCPAgent)(nil)
var _ agents.ArtistURLRetriever = (*MCPAgent)(nil)
func init() {
agents.Register(McpAgentName, mcpConstructor)
}

View File

@@ -0,0 +1,237 @@
package mcp_test
import (
"context"
"errors"
"fmt"
"io"
mcp_client "github.com/metoro-io/mcp-golang" // Renamed alias for clarity
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/agents/mcp"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Define the mcpClient interface locally for mocking, matching the one
// used internally by MCPNative/MCPWasm.
type mcpClient interface {
Initialize(ctx context.Context) (*mcp_client.InitializeResponse, error)
CallTool(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error)
}
// mockMCPClient is a mock implementation of mcpClient for testing.
type mockMCPClient struct {
InitializeFunc func(ctx context.Context) (*mcp_client.InitializeResponse, error)
CallToolFunc func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error)
callToolArgs []any // Store args for verification
callToolName string // Store tool name for verification
}
func (m *mockMCPClient) Initialize(ctx context.Context) (*mcp_client.InitializeResponse, error) {
if m.InitializeFunc != nil {
return m.InitializeFunc(ctx)
}
return &mcp_client.InitializeResponse{}, nil // Default success
}
func (m *mockMCPClient) CallTool(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
m.callToolName = toolName
m.callToolArgs = append(m.callToolArgs, args)
if m.CallToolFunc != nil {
return m.CallToolFunc(ctx, toolName, args)
}
return &mcp_client.ToolResponse{}, nil
}
// Ensure mock implements the local interface (compile-time check)
var _ mcpClient = (*mockMCPClient)(nil)
var _ = Describe("MCPAgent", func() {
var (
ctx context.Context
// We test the public MCPAgent wrapper, which uses the implementations internally.
// The actual agent instance might be native or wasm depending on McpServerPath
agent agents.Interface // Use the public agents.Interface
mockClient *mockMCPClient
)
BeforeEach(func() {
ctx = context.Background()
mockClient = &mockMCPClient{
callToolArgs: make([]any, 0), // Reset args on each test
}
// Instantiate the real agent using a testing constructor
// This constructor needs to be added to the mcp package.
agent = mcp.NewAgentForTesting(mockClient)
Expect(agent).NotTo(BeNil(), "Agent should be created")
})
// Helper to get the concrete agent type for calling specific methods
getConcreteAgent := func() *mcp.MCPAgent {
concreteAgent, ok := agent.(*mcp.MCPAgent)
Expect(ok).To(BeTrue(), "Agent should be of type *mcp.MCPAgent")
return concreteAgent
}
Describe("GetArtistBiography", func() {
It("should call the correct tool and return the biography", func() {
expectedBio := "This is the artist bio."
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
Expect(toolName).To(Equal(mcp.McpToolNameGetBio))
Expect(args).To(BeAssignableToTypeOf(mcp.ArtistArgs{}))
typedArgs := args.(mcp.ArtistArgs)
Expect(typedArgs.ID).To(Equal("id1"))
Expect(typedArgs.Name).To(Equal("Artist Name"))
Expect(typedArgs.Mbid).To(Equal("mbid1"))
return mcp_client.NewToolResponse(mcp_client.NewTextContent(expectedBio)), nil
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).NotTo(HaveOccurred())
Expect(bio).To(Equal(expectedBio))
})
It("should return error if CallTool fails", func() {
expectedErr := errors.New("mcp tool error")
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return nil, expectedErr
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(HaveOccurred())
// The error originates from the implementation now, check for specific part
Expect(err.Error()).To(SatisfyAny(
ContainSubstring("native MCP agent not ready"), // Error from native
ContainSubstring("WASM MCP agent not ready"), // Error from WASM
ContainSubstring("failed to call native MCP tool"),
ContainSubstring("failed to call WASM MCP tool"),
))
Expect(errors.Is(err, expectedErr)).To(BeTrue())
Expect(bio).To(BeEmpty())
})
It("should return ErrNotFound if CallTool response is empty", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
// Return a response created with no content parts
return mcp_client.NewToolResponse(), nil
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(bio).To(BeEmpty())
})
It("should return ErrNotFound if CallTool response has nil TextContent (simulated by empty string)", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
// Simulate nil/empty text content by creating response with empty string text
return mcp_client.NewToolResponse(mcp_client.NewTextContent("")), nil
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(bio).To(BeEmpty())
})
It("should return comm error if CallTool returns pipe error", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return nil, io.ErrClosedPipe
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(SatisfyAny(
ContainSubstring("native MCP agent process communication error"),
ContainSubstring("WASM MCP agent module communication error"),
))
Expect(errors.Is(err, io.ErrClosedPipe)).To(BeTrue())
Expect(bio).To(BeEmpty())
})
It("should return ErrNotFound if MCP tool returns an error string", func() {
mcpErrorString := "handler returned an error: something went wrong on the server"
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return mcp_client.NewToolResponse(mcp_client.NewTextContent(mcpErrorString)), nil
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(bio).To(BeEmpty())
})
})
Describe("GetArtistURL", func() {
It("should call the correct tool and return the URL", func() {
expectedURL := "http://example.com/artist"
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
Expect(toolName).To(Equal(mcp.McpToolNameGetURL))
Expect(args).To(BeAssignableToTypeOf(mcp.ArtistArgs{}))
typedArgs := args.(mcp.ArtistArgs)
Expect(typedArgs.ID).To(Equal("id2"))
Expect(typedArgs.Name).To(Equal("Another Artist"))
Expect(typedArgs.Mbid).To(Equal("mbid2"))
return mcp_client.NewToolResponse(mcp_client.NewTextContent(expectedURL)), nil
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal(expectedURL))
})
It("should return error if CallTool fails", func() {
expectedErr := errors.New("mcp tool error url")
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return nil, expectedErr
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(SatisfyAny(
ContainSubstring("native MCP agent not ready"), // Error from native
ContainSubstring("WASM MCP agent not ready"), // Error from WASM
ContainSubstring("failed to call native MCP tool"),
ContainSubstring("failed to call WASM MCP tool"),
))
Expect(errors.Is(err, expectedErr)).To(BeTrue())
Expect(url).To(BeEmpty())
})
It("should return ErrNotFound if CallTool response is empty", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
// Return a response created with no content parts
return mcp_client.NewToolResponse(), nil
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(url).To(BeEmpty())
})
It("should return comm error if CallTool returns pipe error", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return nil, fmt.Errorf("write: %w", io.ErrClosedPipe)
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(SatisfyAny(
ContainSubstring("native MCP agent process communication error"),
ContainSubstring("WASM MCP agent module communication error"),
))
Expect(errors.Is(err, io.ErrClosedPipe)).To(BeTrue())
Expect(url).To(BeEmpty())
})
It("should return ErrNotFound if MCP tool returns an error string", func() {
mcpErrorString := "handler returned an error: could not find url"
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return mcp_client.NewToolResponse(mcp_client.NewTextContent(mcpErrorString)), nil
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(url).To(BeEmpty())
})
})
})

View File

@@ -0,0 +1,189 @@
package mcp
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/log"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
)
// httpClient is a shared HTTP client for host function reuse.
var httpClient = &http.Client{
// Consider adding a default timeout
Timeout: 30 * time.Second,
}
// registerHostFunctions defines and registers the host functions (e.g., http_fetch)
// into the provided Wazero runtime.
func registerHostFunctions(ctx context.Context, runtime wazero.Runtime) error {
// Define and Instantiate Host Module "env"
_, err := runtime.NewHostModuleBuilder("env"). // "env" is the conventional module name
NewFunctionBuilder().
WithFunc(httpFetch). // Register our Go function
Export("http_fetch"). // Export it with the name WASM will use
Instantiate(ctx)
if err != nil {
log.Error(ctx, "Failed to instantiate 'env' host module with httpFetch", "error", err)
return fmt.Errorf("instantiate host module 'env': %w", err)
}
log.Info(ctx, "Instantiated 'env' host module with http_fetch function")
return nil
}
// httpFetch is the host function exposed to WASM.
// ... (full implementation as provided previously) ...
// Returns:
// - 0 on success (request completed, results written).
// - 1 on host-side failure (e.g., memory access error, invalid input).
func httpFetch(
ctx context.Context, mod api.Module, // Standard Wazero host function params
// Request details
urlPtr, urlLen uint32,
methodPtr, methodLen uint32,
bodyPtr, bodyLen uint32,
timeoutMillis uint32,
// Result pointers
resultStatusPtr uint32,
resultBodyPtr uint32, resultBodyCapacity uint32, resultBodyLenPtr uint32,
resultErrorPtr uint32, resultErrorCapacity uint32, resultErrorLenPtr uint32,
) uint32 { // Using uint32 for status code convention (0=success, 1=failure)
mem := mod.Memory()
// --- Read Inputs ---
urlBytes, ok := mem.Read(urlPtr, urlLen)
if !ok {
log.Error(ctx, "httpFetch host error: failed to read URL from WASM memory")
// Cannot write error back as we don't have the pointers validated yet
return 1
}
url := string(urlBytes)
methodBytes, ok := mem.Read(methodPtr, methodLen)
if !ok {
log.Error(ctx, "httpFetch host error: failed to read method from WASM memory", "url", url)
return 1 // Bail out
}
method := string(methodBytes)
if method == "" {
method = "GET" // Default to GET
}
var reqBody io.Reader
if bodyLen > 0 {
bodyBytes, ok := mem.Read(bodyPtr, bodyLen)
if !ok {
log.Error(ctx, "httpFetch host error: failed to read body from WASM memory", "url", url, "method", method)
return 1 // Bail out
}
reqBody = bytes.NewReader(bodyBytes)
}
timeout := time.Duration(timeoutMillis) * time.Millisecond
if timeout <= 0 {
timeout = 30 * time.Second // Default timeout matching httpClient
}
// --- Prepare and Execute Request ---
log.Debug(ctx, "httpFetch executing request", "method", method, "url", url, "timeout", timeout)
// Use a specific context for the request, derived from the host function's context
// but with the specific timeout for this call.
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, method, url, reqBody)
if err != nil {
errMsg := fmt.Sprintf("failed to create request: %v", err)
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "error", errMsg)
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
mem.WriteUint32Le(resultStatusPtr, 0) // Write 0 status on creation error
mem.WriteUint32Le(resultBodyLenPtr, 0) // No body
return 0 // Indicate results (including error) were written
}
// TODO: Consider adding a User-Agent?
// req.Header.Set("User-Agent", "Navidrome/MCP-Agent-Host")
resp, err := httpClient.Do(req)
if err != nil {
// Handle client-side errors (network, DNS, timeout)
errMsg := fmt.Sprintf("failed to execute request: %v", err)
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "error", errMsg)
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
mem.WriteUint32Le(resultStatusPtr, 0) // Write 0 status on transport error
mem.WriteUint32Le(resultBodyLenPtr, 0)
return 0 // Indicate results written
}
defer resp.Body.Close()
// --- Process Response ---
statusCode := uint32(resp.StatusCode)
responseBodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
errMsg := fmt.Sprintf("failed to read response body: %v", readErr)
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "status", statusCode, "error", errMsg)
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
mem.WriteUint32Le(resultStatusPtr, statusCode) // Write actual status code
mem.WriteUint32Le(resultBodyLenPtr, 0)
return 0 // Indicate results written
}
// --- Write Results Back to WASM Memory ---
log.Debug(ctx, "httpFetch writing results", "url", url, "method", method, "status", statusCode, "bodyLen", len(responseBodyBytes))
// Write status code
if !mem.WriteUint32Le(resultStatusPtr, statusCode) {
log.Error(ctx, "httpFetch host error: failed to write status code to WASM memory")
return 1 // Host error
}
// Write response body (checking capacity)
if !writeBytesResult(mem, resultBodyPtr, resultBodyCapacity, resultBodyLenPtr, responseBodyBytes) {
// If body write fails (likely due to capacity), write an error message instead.
errMsg := fmt.Sprintf("response body size (%d) exceeds buffer capacity (%d)", len(responseBodyBytes), resultBodyCapacity)
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "status", statusCode, "error", errMsg)
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
mem.WriteUint32Le(resultBodyLenPtr, 0) // Ensure body length is 0 if we wrote an error
} else {
// Write empty error string if body write was successful
mem.WriteUint32Le(resultErrorLenPtr, 0)
}
return 0 // Success
}
// Helper to write string results, respecting capacity. Returns true on success.
func writeStringResult(mem api.Memory, ptr, capacity, lenPtr uint32, result string) bool {
bytes := []byte(result)
return writeBytesResult(mem, ptr, capacity, lenPtr, bytes)
}
// Helper to write byte results, respecting capacity. Returns true on success.
func writeBytesResult(mem api.Memory, ptr, capacity, lenPtr uint32, result []byte) bool {
resultLen := uint32(len(result))
writeLen := resultLen
if writeLen > capacity {
log.Warn(context.Background(), "WASM host write truncated", "requested", resultLen, "capacity", capacity)
writeLen = capacity // Truncate if too large for buffer
}
if writeLen > 0 {
if !mem.Write(ptr, result[:writeLen]) {
log.Error(context.Background(), "WASM host memory write failed", "ptr", ptr, "len", writeLen)
return false // Memory write failed
}
}
// Write the *original* length of the data (even if truncated) so the WASM side knows.
if !mem.WriteUint32Le(lenPtr, resultLen) {
log.Error(context.Background(), "WASM host memory length write failed", "lenPtr", lenPtr, "len", resultLen)
return false // Memory write failed
}
return true
}

View File

@@ -0,0 +1,252 @@
package mcp
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
mcp "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
// MCPNative implements the mcpImplementation interface for running the MCP server as a native process.
type MCPNative struct {
mu sync.Mutex
cmd *exec.Cmd // Stores the running command
stdin io.WriteCloser
client mcpClient
// ClientOverride allows injecting a mock client for testing this specific implementation.
ClientOverride mcpClient // TODO: Consider if this is the best way to test
}
// newMCPNative creates a new instance of the native MCP agent implementation.
func newMCPNative() *MCPNative {
return &MCPNative{}
}
// --- mcpImplementation interface methods ---
func (n *MCPNative) Close() error {
n.mu.Lock()
defer n.mu.Unlock()
n.cleanupResources_locked()
return nil // Currently, cleanup doesn't return errors
}
// --- Internal Helper Methods ---
// ensureClientInitialized starts the MCP server process and initializes the client if needed.
// MUST be called with the mutex HELD.
func (n *MCPNative) ensureClientInitialized_locked(ctx context.Context) error {
// Use override if provided (for testing)
if n.ClientOverride != nil {
if n.client == nil {
n.client = n.ClientOverride
log.Debug(ctx, "Using provided MCP client override for native testing")
}
return nil
}
// Check if already initialized
if n.client != nil {
return nil
}
log.Info(ctx, "Initializing Native MCP client and starting/restarting server process...", "serverPath", McpServerPath)
// Clean up any old resources *before* starting new ones
n.cleanupResources_locked()
hostStdinWriter, hostStdoutReader, nativeCmd, startErr := n.startProcess_locked(ctx)
if startErr != nil {
log.Error(ctx, "Failed to start Native MCP server process", "error", startErr)
// Ensure pipes are closed if start failed (startProcess might handle this, but be sure)
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
return fmt.Errorf("failed to start native MCP server: %w", startErr)
}
// --- Initialize MCP client ---
transport := stdio.NewStdioServerTransportWithIO(hostStdoutReader, hostStdinWriter)
clientImpl := mcp.NewClient(transport)
initCtx, cancel := context.WithTimeout(context.Background(), initializationTimeout)
defer cancel()
if _, initErr := clientImpl.Initialize(initCtx); initErr != nil {
err := fmt.Errorf("failed to initialize native MCP client: %w", initErr)
log.Error(ctx, "Native MCP client initialization failed", "error", err)
// Cleanup the newly started process and close pipes
n.cmd = nativeCmd // Temporarily set cmd so cleanup can kill it
n.cleanupResources_locked()
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
return err
}
// --- Initialization successful, update agent state ---
n.cmd = nativeCmd
n.stdin = hostStdinWriter // This is the pipe the agent writes to
n.client = clientImpl
log.Info(ctx, "Native MCP client initialized successfully", "pid", n.cmd.Process.Pid)
return nil // Success
}
// callMCPTool handles ensuring initialization and calling the MCP tool.
func (n *MCPNative) callMCPTool(ctx context.Context, toolName string, args any) (string, error) {
// Ensure the client is initialized and the server is running (attempts restart if needed)
n.mu.Lock()
err := n.ensureClientInitialized_locked(ctx)
if err != nil {
n.mu.Unlock()
log.Error(ctx, "Native MCP agent initialization/restart failed", "tool", toolName, "error", err)
return "", fmt.Errorf("native MCP agent not ready: %w", err)
}
// Keep a reference to the client while locked
currentClient := n.client
// Unlock mutex *before* making the potentially blocking MCP call
n.mu.Unlock()
// Call the tool using the client reference
log.Debug(ctx, "Calling Native MCP tool", "tool", toolName, "args", args)
response, callErr := currentClient.CallTool(ctx, toolName, args)
if callErr != nil {
// Handle potential pipe closures or other communication errors
log.Error(ctx, "Failed to call Native MCP tool", "tool", toolName, "error", callErr)
// Check if the error indicates a broken pipe, suggesting the server died
// The monitoring goroutine will handle cleanup, just return error here.
if errors.Is(callErr, io.ErrClosedPipe) || strings.Contains(callErr.Error(), "broken pipe") || strings.Contains(callErr.Error(), "EOF") {
log.Warn(ctx, "Native MCP tool call failed, possibly due to server process exit.", "tool", toolName)
// No need to explicitly call cleanup, monitoring goroutine handles it.
return "", fmt.Errorf("native MCP agent process communication error: %w", callErr)
}
return "", fmt.Errorf("failed to call native MCP tool '%s': %w", toolName, callErr)
}
// Process the response (same logic as before)
if response == nil || len(response.Content) == 0 || response.Content[0].TextContent == nil || response.Content[0].TextContent.Text == "" {
log.Warn(ctx, "Native MCP tool returned empty/invalid response", "tool", toolName)
// Treat as not found for agent interface consistency
return "", agents.ErrNotFound // Import agents package if needed, or define locally
}
resultText := response.Content[0].TextContent.Text
if strings.HasPrefix(resultText, "handler returned an error:") {
log.Warn(ctx, "Native MCP tool returned an error message", "tool", toolName, "mcpError", resultText)
return "", agents.ErrNotFound // Treat MCP tool errors as "not found"
}
log.Debug(ctx, "Received response from Native MCP agent", "tool", toolName, "length", len(resultText))
return resultText, nil
}
// cleanupResources closes existing resources (stdin, server process).
// MUST be called with the mutex HELD.
func (n *MCPNative) cleanupResources_locked() {
log.Debug(context.Background(), "Cleaning up Native MCP instance resources...")
if n.stdin != nil {
_ = n.stdin.Close()
n.stdin = nil
}
if n.cmd != nil && n.cmd.Process != nil {
pid := n.cmd.Process.Pid
log.Debug(context.Background(), "Killing native MCP process", "pid", pid)
// Kill the process. Ignore error if it's already done.
if err := n.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
log.Error(context.Background(), "Failed to kill native process", "pid", pid, "error", err)
}
// Wait for the process to release resources. Ignore error.
_ = n.cmd.Wait()
n.cmd = nil
}
// Mark client as invalid
n.client = nil
}
// startProcess starts the MCP server as a native executable and sets up monitoring.
// MUST be called with the mutex HELD.
func (n *MCPNative) startProcess_locked(ctx context.Context) (stdin io.WriteCloser, stdout io.ReadCloser, cmd *exec.Cmd, err error) {
log.Debug(ctx, "Starting native MCP server process", "path", McpServerPath)
// Use Background context for the command itself, as it should outlive the request context (ctx)
cmd = exec.CommandContext(context.Background(), McpServerPath)
stdinPipe, err := cmd.StdinPipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("native stdin pipe: %w", err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
_ = stdinPipe.Close()
return nil, nil, nil, fmt.Errorf("native stdout pipe: %w", err)
}
// Get stderr pipe to stream logs
stderrPipe, err := cmd.StderrPipe()
if err != nil {
_ = stdinPipe.Close()
_ = stdoutPipe.Close()
return nil, nil, nil, fmt.Errorf("native stderr pipe: %w", err)
}
if err = cmd.Start(); err != nil {
_ = stdinPipe.Close()
_ = stdoutPipe.Close()
// stderrPipe gets closed implicitly if cmd.Start() fails
return nil, nil, nil, fmt.Errorf("native start: %w", err)
}
currentPid := cmd.Process.Pid
currentCmd := cmd // Capture the current cmd pointer for the goroutine
log.Info(ctx, "Native MCP server process started", "pid", currentPid)
// Start monitoring goroutine for process exit
go func() {
// Start separate goroutine to stream stderr
go func() {
scanner := bufio.NewScanner(stderrPipe)
for scanner.Scan() {
log.Info("[MCP-SERVER] " + scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Error("Error reading MCP server stderr", "pid", currentPid, "error", err)
}
log.Debug("MCP server stderr pipe closed", "pid", currentPid)
}()
waitErr := currentCmd.Wait() // Wait for the specific process this goroutine monitors
n.mu.Lock()
// Stderr is now streamed, so we don't capture it here anymore.
log.Warn("Native MCP server process exited", "pid", currentPid, "error", waitErr)
// Critical: Check if the agent's current command is STILL the one we were monitoring.
// If it's different, it means cleanup/restart already happened, so we shouldn't cleanup again.
if n.cmd == currentCmd {
n.cleanupResources_locked() // Use the locked version as we hold the lock
log.Info("MCP Native agent state cleaned up after process exit", "pid", currentPid)
} else {
log.Debug("Native MCP process exited, but state already updated/cmd mismatch", "exitedPid", currentPid)
}
n.mu.Unlock()
}()
// Return the pipes connected to the process and the Cmd object
return stdinPipe, stdoutPipe, cmd, nil
}

View File

@@ -0,0 +1,305 @@
package mcp
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
"time"
mcp "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
"github.com/navidrome/navidrome/core/agents" // Needed for ErrNotFound
"github.com/navidrome/navidrome/log"
"github.com/tetratelabs/wazero" // Needed for types
"github.com/tetratelabs/wazero/api" // Needed for types
)
// MCPWasm implements the mcpImplementation interface for running the MCP server as a WASM module.
type MCPWasm struct {
mu sync.Mutex
wasmModule api.Module // Stores the instantiated module
wasmCompiled api.Closer // Stores the compiled module reference for this instance
stdin io.WriteCloser
client mcpClient
// Shared resources (passed in, not owned by this struct)
wasmRuntime api.Closer // Shared Wazero Runtime
wasmCache wazero.CompilationCache // Shared Compilation Cache (can be nil)
preCompiledModule wazero.CompiledModule // Pre-compiled module from constructor
// ClientOverride allows injecting a mock client for testing this specific implementation.
ClientOverride mcpClient // TODO: Consider if this is the best way to test
}
// newMCPWasm creates a new instance of the WASM MCP agent implementation.
// It stores the shared runtime, cache, and the pre-compiled module.
func newMCPWasm(runtime api.Closer, cache wazero.CompilationCache, compiledModule wazero.CompiledModule) *MCPWasm {
return &MCPWasm{
wasmRuntime: runtime,
wasmCache: cache,
preCompiledModule: compiledModule,
}
}
// --- mcpImplementation interface methods ---
// Close cleans up instance-specific WASM resources.
// It does NOT close the shared runtime or cache.
func (w *MCPWasm) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
w.cleanupResources_locked()
return nil
}
// --- Internal Helper Methods ---
// ensureClientInitialized starts the MCP WASM module and initializes the client if needed.
// MUST be called with the mutex HELD.
func (w *MCPWasm) ensureClientInitialized_locked(ctx context.Context) error {
// Use override if provided (for testing)
if w.ClientOverride != nil {
if w.client == nil {
w.client = w.ClientOverride
log.Debug(ctx, "Using provided MCP client override for WASM testing")
}
return nil
}
// Check if already initialized
if w.client != nil {
return nil
}
log.Info(ctx, "Initializing WASM MCP client and starting/restarting server module...", "serverPath", McpServerPath)
w.cleanupResources_locked()
// Check if shared runtime exists
if w.wasmRuntime == nil {
return errors.New("shared Wazero runtime not initialized for MCPWasm")
}
hostStdinWriter, hostStdoutReader, mod, err := w.startModule_locked(ctx)
if err != nil {
log.Error(ctx, "Failed to start WASM MCP server module", "error", err)
// Ensure pipes are closed if start failed
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
// startModule_locked handles cleanup of mod/compiled on error
return fmt.Errorf("failed to start WASM MCP server: %w", err)
}
transport := stdio.NewStdioServerTransportWithIO(hostStdoutReader, hostStdinWriter)
clientImpl := mcp.NewClient(transport)
initCtx, cancel := context.WithTimeout(context.Background(), initializationTimeout)
defer cancel()
if _, initErr := clientImpl.Initialize(initCtx); initErr != nil {
err := fmt.Errorf("failed to initialize WASM MCP client: %w", initErr)
log.Error(ctx, "WASM MCP client initialization failed", "error", err)
// Cleanup the newly started module and close pipes
w.wasmModule = mod // Temporarily set so cleanup can close it
w.wasmCompiled = nil // We don't store the compiled instance ref anymore, just the module instance
w.cleanupResources_locked()
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
return err
}
w.wasmModule = mod
w.wasmCompiled = nil // We don't store the compiled instance ref anymore, just the module instance
w.stdin = hostStdinWriter
w.client = clientImpl
log.Info(ctx, "WASM MCP client initialized successfully")
return nil
}
// callMCPTool handles ensuring initialization and calling the MCP tool.
func (w *MCPWasm) callMCPTool(ctx context.Context, toolName string, args any) (string, error) {
w.mu.Lock()
err := w.ensureClientInitialized_locked(ctx)
if err != nil {
w.mu.Unlock()
log.Error(ctx, "WASM MCP agent initialization/restart failed", "tool", toolName, "error", err)
return "", fmt.Errorf("WASM MCP agent not ready: %w", err)
}
// Keep a reference to the client while locked
currentClient := w.client
// Unlock mutex *before* making the potentially blocking MCP call
w.mu.Unlock()
// Call the tool using the client reference
log.Debug(ctx, "Calling WASM MCP tool", "tool", toolName, "args", args)
response, callErr := currentClient.CallTool(ctx, toolName, args)
if callErr != nil {
// Handle potential pipe closures or other communication errors
log.Error(ctx, "Failed to call WASM MCP tool", "tool", toolName, "error", callErr)
// Check if the error indicates a broken pipe, suggesting the server died
// The monitoring goroutine will handle cleanup, just return error here.
if errors.Is(callErr, io.ErrClosedPipe) || strings.Contains(callErr.Error(), "broken pipe") || strings.Contains(callErr.Error(), "EOF") {
log.Warn(ctx, "WASM MCP tool call failed, possibly due to server module exit.", "tool", toolName)
// No need to explicitly call cleanup, monitoring goroutine handles it.
return "", fmt.Errorf("WASM MCP agent module communication error: %w", callErr)
}
return "", fmt.Errorf("failed to call WASM MCP tool '%s': %w", toolName, callErr)
}
// Process the response (same logic as native)
if response == nil || len(response.Content) == 0 || response.Content[0].TextContent == nil || response.Content[0].TextContent.Text == "" {
log.Warn(ctx, "WASM MCP tool returned empty/invalid response", "tool", toolName)
return "", agents.ErrNotFound
}
resultText := response.Content[0].TextContent.Text
if strings.HasPrefix(resultText, "handler returned an error:") {
log.Warn(ctx, "WASM MCP tool returned an error message", "tool", toolName, "mcpError", resultText)
return "", agents.ErrNotFound // Treat MCP tool errors as "not found"
}
log.Debug(ctx, "Received response from WASM MCP agent", "tool", toolName, "length", len(resultText))
return resultText, nil
}
// cleanupResources closes instance-specific WASM resources (stdin, module, compiled ref).
// It specifically avoids closing the shared runtime or cache.
// MUST be called with the mutex HELD.
func (w *MCPWasm) cleanupResources_locked() {
log.Debug(context.Background(), "Cleaning up WASM MCP instance resources...")
if w.stdin != nil {
_ = w.stdin.Close()
w.stdin = nil
}
// Close the module instance
if w.wasmModule != nil {
log.Debug(context.Background(), "Closing WASM module instance")
ctxClose, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := w.wasmModule.Close(ctxClose); err != nil && !errors.Is(err, context.DeadlineExceeded) {
log.Error(context.Background(), "Failed to close WASM module instance", "error", err)
}
cancel()
w.wasmModule = nil
}
// DO NOT close w.wasmCompiled (instance ref)
// DO NOT close w.preCompiledModule (shared pre-compiled code)
// DO NOT CLOSE w.wasmRuntime or w.wasmCache here!
w.client = nil
}
// startModule loads and starts the MCP server as a WASM module.
// It now uses the pre-compiled module.
// MUST be called with the mutex HELD.
func (w *MCPWasm) startModule_locked(ctx context.Context) (hostStdinWriter io.WriteCloser, hostStdoutReader io.ReadCloser, mod api.Module, err error) {
// Check for pre-compiled module
if w.preCompiledModule == nil {
return nil, nil, nil, errors.New("pre-compiled WASM module is nil")
}
// Create pipes for stdio redirection
wasmStdinReader, hostStdinWriter, err := os.Pipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("wasm stdin pipe: %w", err)
}
// Defer close pipes on error exit
shouldClosePipesOnError := true
defer func() {
if shouldClosePipesOnError {
if wasmStdinReader != nil {
_ = wasmStdinReader.Close()
}
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
// hostStdoutReader and wasmStdoutWriter handled below
}
}()
hostStdoutReader, wasmStdoutWriter, err := os.Pipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("wasm stdout pipe: %w", err)
}
// Defer close pipes on error exit
defer func() {
if shouldClosePipesOnError {
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
if wasmStdoutWriter != nil {
_ = wasmStdoutWriter.Close()
}
}
}()
// Use the SHARDED runtime from the agent struct
runtime, ok := w.wasmRuntime.(wazero.Runtime)
if !ok || runtime == nil {
return nil, nil, nil, errors.New("wasmRuntime is not initialized or not a wazero.Runtime")
}
// Prepare module configuration (host funcs/WASI already instantiated on runtime)
config := wazero.NewModuleConfig().
WithStdin(wasmStdinReader).
WithStdout(wasmStdoutWriter).
WithStderr(os.Stderr).
WithArgs(McpServerPath).
WithFS(os.DirFS("/")) // Keep FS access for now
log.Info(ctx, "Instantiating pre-compiled WASM module (will run _start)...")
var moduleInstance api.Module
instanceErrChan := make(chan error, 1)
go func() {
var instantiateErr error
// Use context.Background() for the module's main execution context
moduleInstance, instantiateErr = runtime.InstantiateModule(context.Background(), w.preCompiledModule, config)
instanceErrChan <- instantiateErr
}()
// Wait briefly for immediate instantiation errors
select {
case instantiateErr := <-instanceErrChan:
if instantiateErr != nil {
log.Error(ctx, "Failed to instantiate pre-compiled WASM module", "error", instantiateErr)
// Pipes closed by defer
return nil, nil, nil, fmt.Errorf("instantiate wasm module: %w", instantiateErr)
}
log.Warn(ctx, "WASM module instantiation returned (exited?) immediately without error.")
case <-time.After(1 * time.Second): // Shorter wait now, instantiation should be faster
log.Debug(ctx, "WASM module instantiation likely blocking (server running), proceeding...")
}
// Start a monitoring goroutine for WASM module exit/error
go func(instanceToMonitor api.Module, errChan chan error) {
// This blocks until the instance created by InstantiateModule exits or errors.
instantiateErr := <-errChan
w.mu.Lock() // Lock the specific MCPWasm instance
log.Warn("WASM module exited/errored", "error", instantiateErr)
// Critical: Check if the agent's current module is STILL the one we were monitoring.
if w.wasmModule == instanceToMonitor {
w.cleanupResources_locked() // Use the locked version
log.Info("MCP WASM agent state cleaned up after module exit/error")
} else {
log.Debug("WASM module exited, but state already updated/module mismatch. No cleanup needed by this goroutine.")
// No need to close anything here, the pre-compiled module is shared
}
w.mu.Unlock()
}(moduleInstance, instanceErrChan)
// Success: prevent deferred cleanup of pipes
shouldClosePipesOnError = false
return hostStdinWriter, hostStdoutReader, moduleInstance, nil
}

View File

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

View File

@@ -13,7 +13,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/cache"
"github.com/xrash/smetrics"
)
@@ -27,6 +27,9 @@ type spotifyAgent struct {
}
func spotifyConstructor(ds model.DataStore) agents.Interface {
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
return nil
}
l := &spotifyAgent{
ds: ds,
id: conf.Server.Spotify.ID,
@@ -35,7 +38,7 @@ func spotifyConstructor(ds model.DataStore) agents.Interface {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.id, l.secret, chc)
return l
}
@@ -88,8 +91,6 @@ func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist,
func init() {
conf.AddHook(func() {
if conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != "" {
agents.Register(spotifyAgentName, spotifyConstructor)
}
agents.Register(spotifyAgentName, spotifyConstructor)
})
}

View File

@@ -18,16 +18,18 @@ import (
type Archiver interface {
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
ZipShare(ctx context.Context, id string, w io.Writer) error
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
func NewArchiver(ms MediaStreamer, ds model.DataStore) Archiver {
return &archiver{ds: ds, ms: ms}
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares}
}
type archiver struct {
ds model.DataStore
ms MediaStreamer
ds model.DataStore
ms MediaStreamer
shares Share
}
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
@@ -51,11 +53,11 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
})
for _, album := range albums {
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
isMultDisc := len(discs) > 1
isMultiDisc := len(discs) > 1
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
"format", format, "bitrate", bitrate, "isMultDisc", isMultDisc, "numTracks", len(album))
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
for _, mf := range album {
file := a.albumFilename(mf, format, isMultDisc)
file := a.albumFilename(mf, format, isMultiDisc)
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
}
}
@@ -69,37 +71,49 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
z := zip.NewWriter(out)
comment := "Downloaded from Navidrome"
if format != "raw" {
if format != "raw" && format != "" {
comment = fmt.Sprintf("%s, transcoded to %s %dbps", comment, format, bitrate)
}
_ = z.SetComment(comment)
return z
}
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultDisc bool) string {
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
_, file := filepath.Split(mf.Path)
if format != "raw" {
file = strings.TrimSuffix(file, mf.Suffix) + format
}
if isMultDisc {
if isMultiDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
return fmt.Sprintf("%s/%s", mf.Album, file)
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
s, err := a.shares.Load(ctx, id)
if err != nil {
return err
}
if !s.Downloadable {
return model.ErrNotAuthorized
}
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true)
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
if err != nil {
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
return err
}
return a.zipPlaylist(ctx, id, format, bitrate, out, pls)
}
func (a *archiver) zipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer, pls *model.Playlist) error {
z := createZipWriter(out, format, bitrate)
mfs := pls.MediaFiles()
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
}
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
z := createZipWriter(out, format, bitrate)
for idx, mf := range mfs {
file := a.playlistFilename(mf, format, idx)
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
@@ -113,44 +127,48 @@ func (a *archiver) zipPlaylist(ctx context.Context, id string, format string, bi
func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) string {
ext := mf.Suffix
if format != "raw" {
if format != "" && format != "raw" {
ext = format
}
file := fmt.Sprintf("%02d - %s - %s.%s", idx+1, mf.Artist, mf.Title, ext)
return file
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
}
func sanitizeName(target string) string {
return strings.ReplaceAll(target, "/", "_")
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
path := mf.AbsolutePath()
w, err := z.CreateHeader(&zip.FileHeader{
Name: filename,
Modified: mf.UpdatedAt,
Method: zip.Store,
})
if err != nil {
log.Error(ctx, "Error creating zip entry", "file", mf.Path, err)
log.Error(ctx, "Error creating zip entry", "file", path, err)
return err
}
var r io.ReadCloser
if format != "raw" {
r, err = a.ms.DoStream(ctx, &mf, format, bitrate)
if format != "raw" && format != "" {
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
} else {
r, err = os.Open(mf.Path)
r, err = os.Open(path)
}
if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", mf.Path, "format", format, err)
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
return err
}
defer func() {
if err := r.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", mf.Path, err)
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err)
}
}()
_, err = io.Copy(w, r)
if err != nil {
log.Error(ctx, "Error zipping file", "file", mf.Path, err)
log.Error(ctx, "Error zipping file", "file", path, err)
return err
}

224
core/archiver_test.go Normal file
View File

@@ -0,0 +1,224 @@
package core_test
import (
"archive/zip"
"bytes"
"context"
"io"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Archiver", func() {
var (
arch core.Archiver
ms *mockMediaStreamer
ds *mockDataStore
sh *mockShare
)
BeforeEach(func() {
ms = &mockMediaStreamer{}
sh = &mockShare{}
ds = &mockDataStore{}
arch = core.NewArchiver(ms, ds, sh)
})
Context("ZipAlbum", func() {
It("zips an album correctly", func() {
mfs := model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
}
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", []model.QueryOptions{{
Filters: squirrel.Eq{"album_id": "1"},
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
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)
Expect(err).To(BeNil())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
})
})
Context("ZipArtist", func() {
It("zips an artist's albums correctly", func() {
mfs := model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
}
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", []model.QueryOptions{{
Filters: squirrel.Eq{"album_artist_id": "1"},
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
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)
Expect(err).To(BeNil())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
})
})
Context("ZipShare", func() {
It("zips a share correctly", func() {
mfs := model.MediaFiles{
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
{ID: "2", Path: "test_data/02 - track2.mp3", Suffix: "mp3", Artist: "Artist 2", Title: "track2"},
}
share := &model.Share{
ID: "1",
Downloadable: true,
Format: "mp3",
MaxBitRate: 128,
Tracks: mfs,
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
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)
Expect(err).To(BeNil())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
})
})
Context("ZipPlaylist", func() {
It("zips a playlist correctly", func() {
tracks := []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
{MediaFile: model.MediaFile{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 2", Title: "track2"}},
}
pls := &model.Playlist{
ID: "1",
Name: "Test Playlist",
Tracks: tracks,
}
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
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)
Expect(err).To(BeNil())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
})
})
})
type mockDataStore struct {
mock.Mock
model.DataStore
}
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
args := m.Called(ctx)
return args.Get(0).(model.MediaFileRepository)
}
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
args := m.Called(ctx)
return args.Get(0).(model.PlaylistRepository)
}
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
return &mockLibraryRepository{}
}
type mockLibraryRepository struct {
mock.Mock
model.LibraryRepository
}
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
return "/music", nil
}
type mockMediaFileRepository struct {
mock.Mock
model.MediaFileRepository
}
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
args := m.Called(options)
return args.Get(0).(model.MediaFiles), args.Error(1)
}
type mockPlaylistRepository struct {
mock.Mock
model.PlaylistRepository
}
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
args := m.Called(id, refreshSmartPlaylists, includeMissing)
return args.Get(0).(*model.Playlist), args.Error(1)
}
type mockMediaStreamer struct {
mock.Mock
core.MediaStreamer
}
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)
}
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}
type mockShare struct {
mock.Mock
core.Share
}
func (m *mockShare) Load(ctx context.Context, id string) (*model.Share, error) {
args := m.Called(ctx, id)
return args.Get(0).(*model.Share), args.Error(1)
}

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -20,19 +20,19 @@ import (
var ErrUnavailable = errors.New("artwork unavailable")
type Artwork interface {
Get(ctx context.Context, artID model.ArtworkID, size int) (io.ReadCloser, time.Time, error)
GetOrPlaceholder(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error)
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, em: em}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
}
type artwork struct {
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
em core.ExternalMetadata
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
}
type artworkReader interface {
@@ -41,10 +41,10 @@ type artworkReader interface {
Reader(ctx context.Context) (io.ReadCloser, string, error)
}
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artID, err := a.getArtworkId(ctx, id)
if err == nil {
reader, lastUpdate, err = a.Get(ctx, artID, size)
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
}
if errors.Is(err, ErrUnavailable) {
if artID.Kind == model.KindArtistArtwork {
@@ -57,8 +57,8 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (re
return reader, lastUpdate, err
}
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artReader, err := a.getArtworkReader(ctx, artID, size)
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artReader, err := a.getArtworkReader(ctx, artID, size, square)
if err != nil {
return nil, time.Time{}, err
}
@@ -107,17 +107,17 @@ func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID,
return artID, nil
}
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int) (artworkReader, error) {
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
var artReader artworkReader
var err error
if size > 0 {
artReader, err = resizedFromOriginal(ctx, a, artID, size)
if size > 0 || square {
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
} else {
switch artID.Kind {
case model.KindArtistArtwork:
artReader, err = newArtistReader(ctx, a, artID, a.em)
artReader, err = newArtistReader(ctx, a, artID, a.provider)
case model.KindAlbumArtwork:
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em)
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
case model.KindMediaFileArtwork:
artReader, err = newMediafileArtworkReader(ctx, a, artID)
case model.KindPlaylistArtwork:

View File

@@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@@ -16,13 +15,15 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("Artwork", func() {
// TODO Fix tests
var _ = XDescribe("Artwork", func() {
var aw *artwork
var ds model.DataStore
var ffmpeg *tests.MockFFmpeg
ctx := log.NewContext(context.TODO())
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
var mfWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
var arMultipleCovers model.Artist
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@@ -30,14 +31,23 @@ var _ = Describe("Artwork", func() {
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/test.mp3"}
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/front.png"}
alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
alMultipleCovers = model.Album{ID: "666", Name: "All options", EmbedArtPath: "tests/fixtures/test.mp3",
ImageFiles: "tests/fixtures/cover.jpg" + consts.Zwsp + "tests/fixtures/front.png",
//alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
//alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
alMultipleCovers = model.Album{
ID: "666",
Name: "All options",
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
//Paths: []string{"tests/fixtures/artist/an-album"},
//ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
// "tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
// "tests/fixtures/artist/an-album/artist.png",
AlbumArtistID: "777",
}
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"}
mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"}
mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"}
@@ -65,7 +75,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("tests/fixtures/test.mp3"))
Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3"))
})
It("returns ErrUnavailable if embed path is not available", func() {
ffmpeg.Error = errors.New("not available")
@@ -87,7 +97,7 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal("tests/fixtures/front.png"))
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
})
It("returns ErrUnavailable if external file is not available", func() {
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
@@ -111,9 +121,36 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(expected))
},
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/cover.jpg"),
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/front.png"),
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/test.mp3"),
Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"),
Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"),
Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"),
)
})
})
Describe("artistArtworkReader", func() {
Context("Multiple covers", func() {
BeforeEach(func() {
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
})
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alMultipleCovers,
})
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
mfAnotherWithEmbed,
})
})
DescribeTable("ArtistArtPriority",
func(priority string, expected string) {
conf.Server.ArtistArtPriority = priority
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(expected))
},
Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"),
Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"),
)
})
})
@@ -174,33 +211,83 @@ var _ = Describe("Artwork", func() {
alMultipleCovers,
})
})
It("returns a PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
Expect(err).ToNot(HaveOccurred())
When("Square is false", func() {
It("returns a PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
br, format, err := asImageReader(r)
Expect(format).To(Equal("image/png"))
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns a JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(br)
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
img, format, err := image.Decode(r)
Expect(format).To(Equal("jpeg"))
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
It("returns a JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
Expect(err).ToNot(HaveOccurred())
When("When square is true", func() {
var alCover model.Album
br, format, err := asImageReader(r)
Expect(format).To(Equal("image/jpeg"))
Expect(err).ToNot(HaveOccurred())
DescribeTable("resize",
func(format string, landscape bool, size int) {
coverFileName := "cover." + format
//dirName := createImage(format, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
//ImageFiles: filepath.Join(dirName, coverFileName),
}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
img, _, err := image.Decode(br)
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
conf.Server.CoverArtPriority = coverFileName
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size))
},
Entry("portrait png image", "png", false, 200),
Entry("landscape png image", "png", true, 200),
Entry("portrait jpg image", "jpg", false, 200),
Entry("landscape jpg image", "jpg", true, 200),
)
})
})
})
//func createImage(format string, landscape bool, size int) string {
// var img image.Image
//
// if landscape {
// img = image.NewRGBA(image.Rect(0, 0, size, size/2))
// } else {
// img = image.NewRGBA(image.Rect(0, 0, size/2, size))
// }
//
// tmpDir := GinkgoT().TempDir()
// f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
// defer f.Close()
// switch format {
// case "png":
// _ = png.Encode(f, img)
// case "jpg":
// _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
// }
//
// return tmpDir
//}

View File

@@ -31,7 +31,7 @@ var _ = Describe("Artwork", func() {
Context("GetOrPlaceholder", func() {
Context("Empty ID", func() {
It("returns placeholder if album is not in the DB", func() {
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0)
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
Expect(err).ToNot(HaveOccurred())
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
@@ -49,7 +49,7 @@ var _ = Describe("Artwork", func() {
Context("Get", func() {
Context("Empty ID", func() {
It("returns an ErrUnavailable error", func() {
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0)
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
Expect(err).To(MatchError(artwork.ErrUnavailable))
})
})

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