Compare commits

...

88 Commits

Author SHA1 Message Date
gd be88105a7f
Merge 0e39b88fdf into 1e73ba4754 2024-04-26 16:38:37 +02:00
github-actions[bot] 1e73ba4754 [web] Rebuild web interface 2024-04-23 20:04:32 +00:00
Alain Nussbaumer b20bdda8e9 [web] Lint source code 2024-04-23 22:02:18 +02:00
Alain Nussbaumer 7d7d38b946 [web] Fix untranslated button 2024-04-23 21:44:35 +02:00
Alain Nussbaumer 4268f41a51 [web] Fix bug preventing the removal of a podcast 2024-04-23 21:14:37 +02:00
Alain Nussbaumer bab6146345 [web] Lint source code 2024-04-23 20:52:57 +02:00
Alain Nussbaumer 978e344ce2 [web] Lint source code 2024-04-23 20:33:42 +02:00
Alain Nussbaumer f156bb357a [web] Lint source code 2024-04-23 20:27:50 +02:00
Alain Nussbaumer 3f3ab829c0 [web] Lint source code 2024-04-23 20:26:08 +02:00
Alain Nussbaumer 195135b1b6 [web] Lint source code 2024-04-23 20:16:11 +02:00
Alain Nussbaumer 4c70105b5e [web] Simplify naming of component property 2024-04-23 20:10:59 +02:00
github-actions[bot] 73abc84979 [web] Rebuild web interface 2024-04-23 15:44:23 +00:00
Alain Nussbaumer d4826695e3 [web] Fix links not being correctly highlighted in dark mode 2024-04-23 17:43:45 +02:00
github-actions[bot] 715e9d32eb [web] Rebuild web interface 2024-04-23 15:37:26 +00:00
Alain Nussbaumer 25e005ff32 [web] Add breadcrumb to navigate through the folders 2024-04-23 17:36:48 +02:00
ejurgensen 263a197da4 Revert "[gh-actions] Use stock Homebrew sqlite"
This reverts commit 6a93172cb9.
2024-04-23 14:46:53 +02:00
Alain Nussbaumer 52a915c8a0 [doc] Add configuration documentation 2024-04-22 17:32:53 +02:00
Alain Nussbaumer 67e67c8db9 [doc] Fix capitalisation of macOS 2024-04-22 17:32:26 +02:00
github-actions[bot] 0873c6cb65 [web] Rebuild web interface 2024-04-21 18:09:35 +00:00
Alain Nussbaumer 1ef62ac3a6 [web] Fix error in GroupedList provoked by the linting 2024-04-21 20:09:00 +02:00
github-actions[bot] 06f658e1c4 [web] Rebuild web interface 2024-04-21 16:21:13 +00:00
Alain Nussbaumer a2000c0bc7 [web] Lint source code 2024-04-21 18:20:40 +02:00
Alain Nussbaumer c3d5c6eab9 [web] Lint source code 2024-04-21 17:59:21 +02:00
github-actions[bot] 0d11f732e1 [web] Rebuild web interface 2024-04-21 15:53:56 +00:00
Alain Nussbaumer d6391621a0 [web] Upgrade to eslint 9.1.0 2024-04-21 17:48:14 +02:00
Alain Nussbaumer b8373a4ee0 [web] Lint source code 2024-04-21 17:44:55 +02:00
Alain Nussbaumer 2fda829ac4 [web] Remove unused variable 2024-04-21 16:14:55 +02:00
Alain Nussbaumer 5115e04664 [web] Lint source code 2024-04-21 13:17:22 +02:00
Alain Nussbaumer 369afe11e3 [web] Remove unused variable 2024-04-21 12:58:15 +02:00
Alain Nussbaumer 9690bc2447 [web] Format source code 2024-04-21 12:55:49 +02:00
github-actions[bot] acf8805dac [web] Rebuild web interface 2024-04-20 21:26:29 +00:00
Alain Nussbaumer 58fbcd7e7a [web] Disable Save button when no playlist name is provided 2024-04-20 23:25:58 +02:00
Alain Nussbaumer ae973f312a [web] Remove extraneous space 2024-04-20 23:24:43 +02:00
Alain Nussbaumer 185e09c118 [web] Revert back to older version of eslint until configuration is adapted 2024-04-20 23:00:15 +02:00
github-actions[bot] 595c91d5d6 [web] Rebuild web interface 2024-04-20 20:36:33 +00:00
Alain Nussbaumer 465232f8b9 [web] Upgrade to newer versions of libraries 2024-04-20 22:35:58 +02:00
Alain Nussbaumer 13ff8fdb8e [web] Fix color of delete tag button for the dark mode 2024-04-20 22:35:58 +02:00
Alain Nussbaumer 5ce78d041d [web] Remove blanks in the search query before launching a search 2024-04-20 22:35:58 +02:00
ejurgensen 6a93172cb9
[gh-actions] Use stock Homebrew sqlite
GH action's Homebrew should install has sqlite 3.43.0_1+, which has unlock-notify
2024-04-19 08:50:13 +02:00
ejurgensen f00aae6c6c
[gh-actions] Attempt fix for macOS workflow broken by mxml 4 2024-04-19 08:40:51 +02:00
github-actions[bot] 16b9de01c7 [web] Rebuild web interface 2024-04-15 20:51:26 +00:00
Alain Nussbaumer 1ccc97d824 [web] Present a numeric keypad for integer input fields 2024-04-15 22:50:48 +02:00
Alain Nussbaumer a2dd2251c9 [web] Allow only numerical values in the integer input field 2024-04-15 22:50:48 +02:00
ejurgensen 72454de4ef [jsonapi] Fix boolean value assigned to pointer (from cppcheck) 2024-04-10 22:41:08 +02:00
ejurgensen 677aceccb6 [cast] Add missing include 2024-04-10 22:40:52 +02:00
ejurgensen 60872e0a5a
Merge pull request #1749 from hacketiwack/master
[workflow] Upgrade GitHub actions to newer versions
2024-04-10 22:01:26 +02:00
Alain Nussbaumer c1842e383a [doc] Fix links in the documentation 2024-04-10 14:51:55 +02:00
Alain Nussbaumer 867ab0e80a [docs] Fix links in the documentation 2024-04-10 14:40:27 +02:00
Alain Nussbaumer 59a734b04c [workflow] Upgrade GitHub actions to newer versions 2024-04-10 13:39:09 +02:00
github-actions[bot] 183f6f8ed9 [web] Rebuild web interface 2024-04-09 16:52:02 +00:00
Alain Nussbaumer ff9537514a [web] Fix a bug preventing the files page to not load when refreshing page 2024-04-09 18:51:21 +02:00
github-actions[bot] 60f14adb47 [web] Rebuild web interface 2024-04-09 13:42:12 +00:00
Alain Nussbaumer 5e39828966 [web] Update to newer versions of libraries 2024-04-09 15:41:38 +02:00
Alain Nussbaumer 0362896bfb [web] Add the possibility to remove past search queries 2024-04-09 15:41:38 +02:00
Alain Nussbaumer e5e7702fc5 [web] Streamline search pages 2024-04-09 15:41:38 +02:00
ejurgensen c96c3966f4 [gh-actions] [gh-actions] Try to fix MacOS workflow (use mxml 3) mk3 2024-04-06 23:18:24 +02:00
ejurgensen aaf349bbcc [gh-actions] [gh-actions] Try to fix MacOS workflow (use mxml 3) mk2 2024-04-06 23:06:24 +02:00
ejurgensen cd5937bbb7 [gh-actions] Try to fix MacOS workflow (use mxml 3) 2024-04-06 22:13:35 +02:00
ejurgensen 1c17231b9e [spotify] Fix some logging inaccuracies 2024-04-05 23:45:08 +02:00
ejurgensen a8342dc513 [xcode] Fix for ffmpeg 7.0 that wants const in avio_alloc_context's signature
Closes #1743
2024-04-05 22:44:46 +02:00
ejurgensen 945bde7c66 [db] Fix memleak if db backup is enabled but fails
Closes issue #1741
2024-04-03 22:04:39 +02:00
github-actions[bot] 1c26681a65 [web] Rebuild web interface 2024-04-03 15:49:52 +00:00
Alain Nussbaumer 31661edc03 [web] Fix the search page source when clicking the search menu 2024-04-03 17:46:33 +02:00
Alain Nussbaumer 4946c0e43c [web] Add a catch all redirection if a non-existing link is entered 2024-04-03 17:14:56 +02:00
Alain Nussbaumer 81d9b1723f [web] Lint source code 2024-04-03 16:39:48 +02:00
github-actions[bot] 089df85c1d [web] Rebuild web interface 2024-04-01 18:59:58 +00:00
Alain Nussbaumer 839e475c3e [web] Fix a bug preventing the "featured playlists" and "new releases" pages to work after a page refresh 2024-04-01 20:42:05 +02:00
Alain Nussbaumer 72b30aabf9 [web] Fix a bug preventing playlist page to open sub folders 2024-04-01 20:40:08 +02:00
github-actions[bot] 40c423ee3c [web] Rebuild web interface 2024-04-01 14:41:51 +00:00
Alain Nussbaumer d49074eeae [web] Fix folder page not reloading data 2024-04-01 16:32:47 +02:00
Alain Nussbaumer be931f4173 [web] Lint source code 2024-04-01 15:13:42 +02:00
Alain Nussbaumer 5640c33a67 [web] Remove duplicate loading of data before route update 2024-03-31 23:55:19 +02:00
github-actions[bot] 285270f598 [web] Rebuild web interface 2024-03-31 19:53:24 +00:00
Alain Nussbaumer 4b52df676a [web] Update axios library version 2024-03-31 21:52:49 +02:00
Alain Nussbaumer 7b41980ace [web] Lint source code 2024-03-31 21:52:49 +02:00
Alain Nussbaumer cbedb4d38c [web] Fix count of albums and tracks in genre pages 2024-03-31 21:52:49 +02:00
Alain Nussbaumer 2451ac608f [web] Update libraries to their newer versions 2024-03-31 21:52:49 +02:00
Alain Nussbaumer 7be1989cd4 [web] Display only icons in tabs when on mobile
No need to scroll horizontally to switch tabs on the music and audiobook pages anymore.
2024-03-31 21:52:49 +02:00
ejurgensen 3e7e03b4c1 [jsonapi] Support /api/search?type=genre like type=composers is supported
Ref. #1735
2024-03-30 22:24:53 +01:00
github-actions[bot] 39f5df8ade [web] Rebuild web interface 2024-03-29 14:32:43 +00:00
Alain Nussbaumer 0a0568c2f5 [web] Unused API methods removed 2024-03-29 15:32:12 +01:00
Alain Nussbaumer 6577004536 [docs] Clean up the docs 2024-03-29 02:54:34 +01:00
Alain Nussbaumer ad2d0e0bba [docs] Fix spelling mistakes 2024-03-29 02:19:51 +01:00
github-actions[bot] eecd276aa3 [web] Rebuild web interface 2024-03-28 14:46:13 +00:00
Alain Nussbaumer 06a23ea29a [web] Check validity of URL 2024-03-28 15:45:39 +01:00
gd 0e39b88fdf [mpd] noidle command: notify pending idle events before leaving idle state.
mpd_notify_idle_client returns 0 if it sent the idle response to the client.
2023-06-30 10:55:36 +03:00
gd 44d91ab858 [mpd] Fix: allow password command when not authenticated 2023-05-21 15:45:36 +03:00
gd b7dd32b64e [mpd,db] MPD protocol fixes to handling of idle/noidle command and command list.
Command handling:
1. Changed mpd_read_cb() to delegate to mpd_process_line() for each received
command line.
2. mpd_process_line() handles idle state and command list state and delegates
to mpd_process_command() to handle each command line.
If the command was successful it sends OK to the client according the the
command list state.
Error responses are sent by mpd_process_command().
3. mpd_process_command() parses the args and delegates to the individual
command handler.

mpd_input_filter:
1. Removed handling of command lists. They are handled by mpd_process_line().
2. Return BEV_OK if there's at least one complete line of input even if there's
more data in the input buffer.

Idle/noidle:
1. Changed mpd_command_idle() to never write OK to the output buffer.
Instead it is the responsibility of the caller to decide on the response.

2. Removed mpd_command_noidle() instead it is handled in mpd_process_line().
If the client is not in idle state noidle is ignored (no response sent)
If the client is in idle state then it changes idle state to false and sends
OK as the response to the idle command.

Command lists:
1. Added command list state to the client context so commands in the list are
buffered and only executed after receiving command_list_end.

Connection state:
1. Added is_closing flag in the client context to ignore messages received
after freeing the events buffer in intent to close the client connection.

Command arguments parsing:
1. Updated COMMAND_ARGV_MAX to 70 to match current MPD.
2. Changed mpd_pars_range_arg to handle open-ended range.

Command pause:
1. pause is ignored in stopped state instead returning error.

Command move:
1. Changed mpd_command_move() to support moving a range.
2. Added db_queue_move_bypos_range() to support moving a range.

Command password:
1. Password authentication flag set in handler mpd_command_password() instead
of in command processor.

Config:
1. Added config value: "max_command_list_size".
   The maximum allowed size of buffered commands in command list.
2023-05-21 15:05:09 +03:00
105 changed files with 3536 additions and 2147 deletions

View File

@ -29,7 +29,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
config-file: ./.github/codeql/codeql-config.yml
# Override language selection by uncommenting this and choosing your languages
@ -39,7 +39,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
#- name: Autobuild
# uses: github/codeql-action/autobuild@v1
# uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@ -55,4 +55,4 @@ jobs:
scan-build --status-bugs -disable-checker deadcode.DeadStores --exclude src/parsers make
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

View File

@ -38,6 +38,12 @@ jobs:
sudo ln -s /usr/local/opt/bison/bin/bison /usr/local/bin/bison
sudo ln -s /usr/local/opt/flex/bin/flex /usr/local/bin/flex
- name: Install libmxml
# Homebrew by default comes with libmxml 4, but it isn't compatible with mxml 3 which we need
run: |
wget https://raw.githubusercontent.com/Homebrew/homebrew-core/71bfcd3624ee88eee1e2ea6653753dafd48e7fcf/Formula/lib/libmxml.rb
brew install --build-from-source libmxml.rb
- name: Install libinotify-kqueue
# brew does not have libinotify package
run: |
@ -69,7 +75,8 @@ jobs:
brew install ffmpeg
- name: Install other dependencies
run: brew install libunistring libmxml confuse libplist libwebsockets libevent libgcrypt json-c protobuf-c libsodium gnutls pulseaudio openssl
run: |
brew install libunistring confuse libplist libwebsockets libevent libgcrypt json-c protobuf-c libsodium gnutls pulseaudio openssl
- name: Configure
# We configure a non-privileged setup, since how to add a "owntone" system

View File

@ -9,10 +9,10 @@ You control the server via a web interface, Apple Remote, an Android remote
(e.g. Retune), an MPD client, json API or DACP.
OwnTone also serves local files via the Digital Audio Access Protocol (DAAP) to
iTunes (Windows), Apple Music (MacOS) and Rhythmbox (Linux), and via the Roku
iTunes (Windows), Apple Music (macOS) and Rhythmbox (Linux), and via the Roku
Server Protocol (RSP) to Roku devices.
Runs on Linux, BSD and MacOS.
Runs on Linux, BSD and macOS.
OwnTone was previously called forked-daapd, which again was a rewrite of
mt-daapd (Firefly Media Server).

View File

@ -15,7 +15,7 @@ Alternative ALSA names can be used to refer to physical ALSA devices and can be
The ALSA device information required for configuration the server can be determined using `aplay`, as described in the rest of this document, but OwnTone can also assist; when configured to log at `INFO` level the following information is provided during startup:
```
```shell
laudio: Available ALSA playback mixer(s) on hw:0 CARD=Intel (HDA Intel): 'Master' 'Headphone' 'Speaker' 'PCM' 'Mic' 'Beep'
laudio: Available ALSA playback mixer(s) on hw:1 CARD=E30 (E30): 'E30 '
laudio: Available ALSA playback mixer(s) on hw:2 CARD=Seri (Plantronics Blackwire 3210 Seri): 'Sidetone' 'Headset'
@ -29,7 +29,7 @@ On this machine the server reports that it can see the onboard HDA Intel sound c
OwnTone can support a single ALSA device or multiple ALSA devices.
```
```conf
# example audio section for server for a single sound card
audio {
nickname = "Computer"
@ -43,7 +43,7 @@ audio {
Multiple devices can be made available to OwnTone using separate `alsa { .. }` sections.
```
```conf
audio {
type = "alsa"
}
@ -63,11 +63,11 @@ NB: When introducing `alsa { .. }` section(s) the ALSA specific configuration in
If there is only one sound card, verify if the `default` sound device is correct for playback, we will use the `aplay` utility.
```
```shell
# generate some audio if you don't have a wav file to hand
$ sox -n -c 2 -r 44100 -b 16 -C 128 /tmp/sine441.wav synth 30 sin 500-100 fade h 0.2 30 0.2
sox -n -c 2 -r 44100 -b 16 -C 128 /tmp/sine441.wav synth 30 sin 500-100 fade h 0.2 30 0.2
$ aplay -Ddefault /tmp/sine441.wav
aplay -Ddefault /tmp/sine441.wav
```
If you can hear music played then you are good to use `default` for the server configuration. If you can not hear anything from the `aplay` firstly verify (using `alsamixer`) that the sound card is not muted. If the card is not muted AND there is no sound you can try the options below to determine the card and mixer for configuring the server.
@ -76,7 +76,7 @@ If you can hear music played then you are good to use `default` for the server c
As shown above, OwnTone can help, consider the information that logged:
```
```log
laudio: Available ALSA playback mixer(s) on hw:0 CARD=Intel (HDA Intel): 'Master' 'Headphone' 'Speaker' 'PCM' 'Mic' 'Beep'
laudio: Available ALSA playback mixer(s) on hw:1 CARD=E30 (E30): 'E30 '
laudio: Available ALSA playback mixer(s) on hw:2 CARD=Seri (Plantronics Blackwire 3210 Seri): 'Sidetone' 'Headset'
@ -84,7 +84,7 @@ laudio: Available ALSA playback mixer(s) on hw:2 CARD=Seri (Plantronics Blackwir
Using the information above, we can see 3 sound cards that we could use with OwnTone with the first sound card having a number of separate mixer devices (volume control) for headphone and the internal speakers - we'll configure the server to use both these and also the E30 device. The server configuration for these multiple outputs would be:
```
```conf
# using ALSA device alias where possible
alsa "hw:Intel" {
@ -110,12 +110,14 @@ alsa "plughw:E30" {
NB: it is troublesome to use `hw` or `plughw` ALSA addressing when running OwnTone on a machine with `pulseaudio` and if you wish to use refer to ALSA devices directly that you stop `pulseaudio`.
## Manually Determining the sound cards you have / ALSA can see
The example below is how I determined the correct sound card and mixer values for a Raspberry Pi that has an additional DAC card (hat) mounted. Of course using the log output from the server would have given the same results.
Use `aplay -l` to list all the sound cards and their order as known to the system - you can have multiple `card X, device Y` entries; some cards can also have multiple playback devices such as the RPI's onboard sound card which feeds both headphone (card 0, device 0) and HDMI (card 0, device 1).
```shell
$ aplay -l
**** List of PLAYBACK Hardware Devices ****
card 0: ALSA [bcm2835 ALSA], device 0: bcm2835 ALSA [bcm2835 ALSA]
Subdevices: 6/7
@ -142,6 +144,7 @@ Use `aplay -L` to get more information about the PCM devices defined on the syst
```shell
$ aplay -L
null
Discard all samples (playback) or generate zero samples (capture)
default:CARD=ALSA
@ -196,7 +199,7 @@ plughw:CARD=IQaudIODAC,DEV=0
For the server configuration, we will use:
```
```conf
audio {
nickname = "Computer"
type = "alsa"
@ -237,7 +240,7 @@ This card has multiple controls but we want to find a mixer control listed with
For the server configuration, we will use:
```
```conf
audio {
nickname = "Computer"
type = "alsa"
@ -251,8 +254,7 @@ audio {
This is the name of the underlying physical device used for the mixer - it is typically the same value as the value of `card` in which case a value is not required by the server configuration. An example of when you want to change explicitly configure this is if you need to use a `dmix` device (see below).
## Handling Devices that cannot concurrently play multiple audio streams
## Handling Devices that cannot concurrently play multiple audio streams
Some devices such as various RPI DAC boards (IQaudio DAC, Allo Boss DAC...) cannot have multiple streams opened at the same time/cannot play multiple sound files at the same time. This results in `Device or resource busy` errors. You can confirm if your sound card has this problem by using the example below once have determined the names/cards information as above.
@ -302,7 +304,7 @@ The downside to the `dmix` approach will be the need to fix a sample rate (48000
A `dmix` device can be defined in `/etc/asound.conf` or `~/.asoundrc` for the same user running OwnTone. We will need to know the underlying physical sound card to be used: in our examples above, `hw:1,0` / `card 1, device 0` representing our IQaudIODAC as per output of `aplay -l`. We also take the `buffer_size` and `period_size` from the output of playing a sound file via `aplay -v`.
```
```conf
# use 'dac' as the name of the device: "aplay -Ddac ...."
pcm.!dac {
type plug
@ -317,11 +319,11 @@ pcm.dmixer {
ipc_perm 0666 # multi-user sharing permissions
slave {
pcm "hw:1,0" # points at the underlying device - could also simply be hw:1
period_time 0
period_size 4096 # from the output of aplay -v
buffer_size 22052 # from the output of aplay -v
rate 44100 # locked in sample rate for resampling on dmix device
pcm "hw:1,0" # points at the underlying device - could also simply be hw:1
period_time 0
period_size 4096 # from the output of aplay -v
buffer_size 22052 # from the output of aplay -v
rate 44100 # locked in sample rate for resampling on dmix device
}
hint.description "IQAudio DAC s/w dmix device"
}
@ -360,7 +362,7 @@ We will use the newly defined card named `dac` which uses the underlying `hw:1`
For the final server configuration, we will use:
```
```conf
audio {
nickname = "Computer"
type = "alsa"
@ -378,7 +380,7 @@ Once installed the user must setup a virtual device and use this device in the s
If you wish to use your `hw:0` device for output:
```
```conf
# /etc/asound.conf
ctl.equal {
type equal;
@ -402,7 +404,7 @@ pcm.equal {
and in `owntone.conf`
```
```conf
alsa "equal" {
nickname = "Equalised Output"
# adjust accordingly for mixer with pvolume capability
@ -424,7 +426,7 @@ Note however, the equalizer appears to require a `plughw` device which means you
`mixer` value is wrong. Verify name of `mixer` value in server config against the names from all devices capable of playback using `amixer -c <card number>`. Assume the device is card 1:
```
```shell
(IFS=$'\n'
CARD=1
for i in $(amixer -c ${CARD} scontrols | awk -F\' '{ print $2 }'); do
@ -433,9 +435,9 @@ Note however, the equalizer appears to require a `plughw` device which means you
)
```
Look at the names output and choose the one that fits. The outputs can be something like:
Look at the names output and choose the one that fits. The outputs can be something like:
```
```shell
# laptop
Master
Headphone
@ -454,16 +456,16 @@ Note however, the equalizer appears to require a `plughw` device which means you
* No sound during playback - valid mixer/verified by aplay
Check that the mixer is not muted or volume set to 0. Using the value of `mixer` as per server config and unmute or set volume to max. Assume the device is card 1 and `mixer = Analogue`:
Check that the mixer is not muted or volume set to 0. Using the value of `mixer` as per server config and unmute or set volume to max. Assume the device is card 1 and `mixer = Analogue`:
```
```shell
amixer -c 1 set Analogue unmute ## some mixers can not be muted resulting in "invalid command"
amixer -c 1 set Analogue 100%
```
An example of a device with volume turned all the way down - notice the `Playback` values are `0`[0%]`:
```
```shell
Simple mixer control 'Analogue',0
Capabilities: pvolume
Playback channels: Front Left - Front Right
@ -476,7 +478,7 @@ Note however, the equalizer appears to require a `plughw` device which means you
* Server stops playing after moving to new track in paly queue, Error in log `Could not open playback device`
The log contains these log lines:
```
```log
[2019-06-19 20:52:51] [ LOG] laudio: open '/dev/snd/pcmC0D0p' failed (-16)[2019-06-19 20:52:51] [ LOG] laudio: Could not open playback device: Device or resource busy
[2019-06-19 20:52:51] [ LOG] laudio: Device 'hw' does not support quality (48000/16/2), falling back to default
[2019-06-19 20:52:51] [ LOG] laudio: open '/dev/snd/pcmC0D0p' failed (-16)[2019-06-19 20:52:51] [ LOG] laudio: Could not open playback device: Device or resource busy

View File

@ -10,7 +10,7 @@ First, understand what and how the particular stream is sending information.
information. `ffprobe <http://stream.url>` should give you some useful output,
look at the Metadata section, below is an example.
```
```m3u
Metadata:
icy-br : 320
icy-description : DJ-mixed blend of modern and classic rock, electronica, world music, and more. Always 100% commercial-free
@ -29,7 +29,7 @@ StreamUrl points to the artwork image file.
Below is another example that will require some tweaks to the server, Notice
`icy-name` is blank and `StreamUrl` doesn't point to an image.
```
```m3u
Metadata:
icy-br : 127
icy-pub : 0
@ -43,10 +43,11 @@ Metadata:
In the above, first fix is the blank name, second is the image artwork.
### 1) Set stream name/title via the M3U file
## 1) Set stream name/title via the M3U file
Set the name with an EXTINF tag in the m3u playlist file:
```
```m3u
#EXTM3U
#EXTINF:-1, - My Radio Stream Name
http://radio.stream.domain/stream.url
@ -57,7 +58,8 @@ Length is -1 since it's a stream, `<Artist Name>` was left blank since
`StreamTitle` is accurate in the Metadata but `<Artist Title>` was set to
`My Radio Stream Name` since `icy-name` was blank.
### 2) StreamUrl is a JSON file with metadata
## 2) StreamUrl is a JSON file with metadata
If `StreamUrl` does not point directly to an artwork file then the link may be
to a json file that contains an artwork link. If so, you can make the server
download the file automatically and search for an artwork link, and also track
@ -91,7 +93,8 @@ curl -X PUT "http://localhost:3689/api/settings/misc/streamurl_keywords_artwork_
If you want multiple search phrases then comma separate, e.g. "duration,length".
### 3) Set metadata with a custom script
## 3) Set metadata with a custom script
If your radio station publishes metadata via another method than the above, e.g.
just on their web site, then you will have to write a script that pulls the
metadata and then pushes it to the server. To update metadata for the

View File

@ -15,7 +15,7 @@ artwork (group artwork) by the following procedure:
- failing that, if [directory name].{png,jpg} is found in one of the
directories containing files that are part of the group, it is used as the
artwork. The first file found is used, ordering is not guaranteed;
- failing that, individual files are examined and the first file found
- failing that, individual files are examined and the first file found
with an embedded artwork is used. Here again, ordering is not guaranteed.
{artwork,cover,Folder} are the default, you can add other base names in the
@ -28,7 +28,7 @@ the list, OwnTone will look for /foo/bar.{jpg,png}.
You can use symlinks for the artwork files.
OwnTone caches artwork in a separate cache file. The default path is
`/var/cache/owntone/cache.db` and can be configured in the configuration
file. The cache.db file can be deleted without losing the library and pairing
OwnTone caches artwork in a separate cache file. The default path is
`/var/cache/owntone/cache.db` and can be configured in the configuration
file. The cache.db file can be deleted without losing the library and pairing
informations.

View File

@ -1,11 +1,11 @@
# Build instructions for OwnTone
# Build Instructions
This document contains instructions for building OwnTone from the git tree. If
you just want to build from a release tarball, you don't need the build tools
(git, autotools, autoconf, automake, gawk, gperf, gettext, bison and flex), and
you can skip the autoreconf step.
## Quick version for Debian/Ubuntu users
## Quick Version for Debian/Ubuntu
If you are the lucky kind, this should get you all the required tools and
libraries:
@ -110,27 +110,28 @@ running with `sudo systemctl status owntone`.
See the [Documentation](getting-started.md) for usage information.
## Quick version for FreeBSD
## Quick Version for FreeBSD
There is a script in the 'scripts' folder that will at least attempt to do all
the work for you. And should the script not work for you, you can still look
through it and use it as an installation guide.
## Quick version for macOS (using Homebrew)
## Quick Version for macOS Using Homebrew
This workflow file used for building OwnTone via Github actions includes
all the steps that you need to execute:
[.github/workflows/macos.yml](https://github.com/owntone/owntone-server/blob/master/.github/workflows/macos.yml)
## "Quick" version for macOS (using macports)
## "Quick" Version for macOS Using MacPorts
Caution:
1) this approach may be out of date, consider using the Homebrew method above
since it is continuously tested.
2) macports requires many downloads and lots of time to install (and sometimes
build) ports... you'll want a decent network connection and some patience!
2) MacPorts requires many downloads and lots of time to install (and sometimes
build) ports. You will need a decent network connection and some patience!
Install macports (which requires Xcode): <https://www.macports.org/install.php>
Install MacPorts (which requires Xcode): <https://www.macports.org/install.php>
```bash
sudo port install \
@ -139,9 +140,9 @@ sudo port install \
libplist libsodium protobuf-c
```
Download, configure, build and install the Mini-XML library: <http://www.msweet.org/projects.php/Mini-XML>
Download, configure, build, and install the [Mini-XML library](https://www.msweet.org/mxml/)
Download, configure, build and install the libinotify library: <https://github.com/libinotify-kqueue/libinotify-kqueue>
Download, configure, build and install the [libinotify-kqueue library](https://github.com/libinotify-kqueue/libinotify-kqueue)
Add the following to `.bashrc`:
@ -160,14 +161,14 @@ Optional features require the following additional ports:
Chromecast | `--enable-chromecast` | gnutls
PulseAudio | `--with-pulseaudio` | pulseaudio
Clone the OwnTone repo:
Clone the OwnTone repository:
```bash
git clone https://github.com/owntone/owntone-server.git
cd owntone-server
```
Finally, configure, build and install, adding configure arguments for
Finally, configure, build, install, and add configuration arguments for
optional features:
```bash
@ -177,11 +178,11 @@ make
sudo make install
```
Note: if for some reason you've installed the avahi port, you need to
Note: if for some reason you've installed the `avahi` port, you need to
add `--without-avahi` to configure above.
Edit `/usr/local/etc/owntone.conf` and change the `uid` to a nice
system daemon (eg: unknown), and run the following:
Edit `/usr/local/etc/owntone.conf` and change the `uid` to a proper
system daemon (eg: unknown), and run the following commands:
```bash
sudo mkdir -p /usr/local/var/run
@ -195,14 +196,14 @@ Run OwnTone:
sudo /usr/local/sbin/owntone
```
Verify it's running (you need to <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop
Verify it is running (you need to <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop
dns-sd):
```bash
dns-sd -B _daap._tcp
```
## Long version - requirements
## Long Version - Requirements
Required tools:
@ -216,53 +217,34 @@ Required tools:
Libraries:
- Avahi client libraries (avahi-client), 0.6.24 minimum
from <http://avahi.org/>
- sqlite3 3.5.0+ with unlock notify API enabled (read below)
from <http://sqlite.org/download.html>
- ffmpeg (libav)
from <http://ffmpeg.org/>
- libconfuse
from <http://www.nongnu.org/confuse/>
- libevent 2.1.4+
from <http://libevent.org/>
- MiniXML (aka mxml or libmxml)
from <http://minixml.org/software.php>
- gcrypt 1.2.0+
from <http://gnupg.org/download/index.en.html#libgcrypt>
- zlib
from <http://zlib.net/>
- libunistring 0.9.3+
from <http://www.gnu.org/software/libunistring/#downloading>
- libjson-c
from <https://github.com/json-c/json-c/wiki>
- libcurl
from <http://curl.haxx.se/libcurl/>
- libplist 0.16+
from <http://github.com/JonathanBeck/libplist/downloads>
- libsodium
from <https://download.libsodium.org/doc/>
- libprotobuf-c
from <https://github.com/protobuf-c/protobuf-c/wiki>
- libasound (optional - ALSA local audio)
- [Avahi](https://avahi.org/) client libraries (avahi-client) 0.6.24+
- [SQLite](https://sqlite.org/) 3.5.0+ with the unlock notify API enabled.
SQLite needs to be built with the support for the unlock notify API; this is not
always the case in binary packages, so you may need to rebuild SQLite to
enable the unlock notify API. You can check for the presence of the
`sqlite3_unlock_notify` symbol in the sqlite library. Refer to the `SQLITE_ENABLE_UNLOCK_NOTIFY` in the SQLlite documentation.
- [FFmpeg](https://ffmpeg.org/)
- [libconfuse](https://github.com/libconfuse/libconfuse)
- [libevent](https://libevent.org/) 2.1.4+
- [Mini-XML](https://www.msweet.org/mxml/) (aka mxml or libmxml)
- [Libgcrypt](https://gnupg.org/software/libgcrypt/) 1.2.0+
- [zlib](https://zlib.net/)
- [libunistring](https://www.gnu.org/software/libunistring/) 0.9.3+
- [json-c](https://github.com/json-c/json-c/)
- [libcurl](https://curl.se/libcurl/)
- [libplist](https://github.com/JonathanBeck/libplist/) 0.16+
- [libsodium](https://doc.libsodium.org/)
- [protobuf-c](https://github.com/protobuf-c/protobuf-c/)
- [alsa-lib](https://github.com/alsa-project/alsa-lib/) (optional - ALSA local audio)
often already installed as part of your distro
- libpulse (optional - Pulseaudio local audio)
from <https://www.freedesktop.org/wiki/Software/PulseAudio/Download/>
- libgnutls (optional - Chromecast support)
from <http://www.gnutls.org/>
- libwebsockets 2.0.2+ (optional - websocket support)
from <https://libwebsockets.org/>
- [PulseAudio](https://www.freedesktop.org/wiki/Software/PulseAudio/) (optional - PulseAudio local audio)
- [GnuTLS](https://www.gnutls.org/) (optional - Chromecast support)
- [Libwebsockets](https://libwebsockets.org/) 2.0.2+ (optional - websocket support)
If using binary packages, remember that you need the development packages to
build OwnTone (usually named -dev or -devel).
Note: If using binary packages, remember that you need the development packages to
build OwnTone (usually suffixed with -dev or -devel).
sqlite3 needs to be built with support for the unlock notify API; this isn't
always the case in binary packages, so you may need to rebuild sqlite3 to
enable the unlock notify API (you can check for the presence of the
sqlite3_unlock_notify symbol in the sqlite3 library). Refer to the sqlite3
documentation, look for `SQLITE_ENABLE_UNLOCK_NOTIFY`.
## Long version - building and installing
## Long Version - Building and Installing
Start by generating the build system by running `autoreconf -i`. This will
generate the configure script and `Makefile.in`.
@ -292,13 +274,13 @@ The source for the player web interface is located under the `web-src` folder an
requires nodejs >= 6.0 to be built. In the `web-src` folder run `npm install` to
install all dependencies for the player web interface. After that run `npm run build`.
This will build the web interface and update the `htdocs` folder.
(See [Web interface](clients/web-interface.md) for more
informations)
(See [Web interface](clients/web-interface.md) for more informations)
Building with libwebsockets is required if you want the web interface. It will be enabled
if the library is present (with headers). Use `--without-libwebsockets` to disable.
Building with libwebsockets is required if you want the web interface.
It will be enabled if the library is present (with headers).
Use `--without-libwebsockets` to disable.
Building with Pulseaudio is optional. It will be enabled if the library is
Building with PulseAudio is optional. It will be enabled if the library is
present (with headers). Use `--without-pulseaudio` to disable.
Recommended build settings:
@ -328,7 +310,7 @@ if it's started as root.
This user must have read permission to your library and read/write permissions
to the database location (`$localstatedir/cache/owntone` by default).
## Non-priviliged user version (for development)
## Non-Priviliged User Version for Development
OwnTone is meant to be run as system wide daemon, but for development purposes
you may want to run it isolated to your regular user.
@ -337,6 +319,7 @@ The following description assumes that you want all runtime data stored in
`$HOME/owntone_data` and the source in `$HOME/projects/owntone-server`.
Prepare directories for runtime data:
```bash
mkdir -p $HOME/owntone_data/etc
mkdir -p $HOME/owntone_data/media
@ -345,6 +328,7 @@ mkdir -p $HOME/owntone_data/media
Copy one or more mp3 file to test with to `owntone_data/media`.
Checkout OwnTone and configure build:
```bash
cd $HOME/projects
git clone https://github.com/owntone/owntone-server.git
@ -362,10 +346,10 @@ make install
Edit `owntone_data/etc/owntone.conf`, find the following configuration settings
and set them to these values:
```
uid = ${USER}
loglevel = "debug"
directories = { "${HOME}/owntone_data/media" }
```conf
uid = ${USER}
loglevel = "debug"
directories = { "${HOME}/owntone_data/media" }
```
Run the server:
@ -373,4 +357,5 @@ Run the server:
```bash
./src/owntone -f
```
(you can also use the copy of the binary in `$HOME/owntone_data/usr/sbin`)
Note: You can also use the copy of the binary located in `$HOME/owntone_data/usr/sbin`

View File

@ -5,7 +5,7 @@ You can - to some extent - use clients for MPD to control OwnTone.
By default OwnTone listens on port 6600 for MPD clients. You can change
this in the configuration file.
Currently only a subset of the commands offered by MPD (see [MPD protocol documentation](http://www.musicpd.org/doc/protocol/)) are supported.
Currently only a subset of the commands offered by MPD (see [MPD Protocol](https://mpd.readthedocs.io/en/latest/protocol.html)) are supported.
Due to some differences between OwnTone and MPD not all commands will act the
same way they would running MPD:
@ -18,5 +18,5 @@ The following table shows what is working for a selection of MPD clients:
| Client | Type | Status |
| --------------------------------------------- | ------ | --------------- |
| [mpc](http://www.musicpd.org/clients/mpc/) | CLI | Working commands: mpc, add, crop, current, del (ranges are not yet supported), play, next, prev (behaves like cdprev), pause, toggle, cdprev, seek, clear, outputs, enable, disable, playlist, ls, load, volume, repeat, random, single, search, find, list, update (initiates an init-rescan, the path argument is not supported) |
| [mpc](https://www.musicpd.org/clients/mpc/) | CLI | Working commands: mpc, add, crop, current, del (ranges are not yet supported), play, next, prev (behaves like cdprev), pause, toggle, cdprev, seek, clear, outputs, enable, disable, playlist, ls, load, volume, repeat, random, single, search, find, list, update (initiates an init-rescan, the path argument is not supported) |
| [ympd](http://www.ympd.org/) | Web | Everything except "add stream" should work |

View File

@ -2,7 +2,7 @@
Remote gets a list of output devices from the server; this list includes any
and all devices on the network we know of that advertise AirPlay: AirPort
Express, Apple TV, ... It also includes the local audio output, that is, the
Express, Apple TV, It also includes the local audio output, that is, the
sound card on the server (even if there is no sound card).
OwnTone remembers your selection and the individual volume for each
@ -11,48 +11,53 @@ they return online during playback.
## Pairing
1. Open the [web interface](http://owntone.local:3689)
2. Start Remote, go to Settings, Add Library
3. Enter the pair code in the web interface (update the page with F5 if it does
not automatically pick up the pairing request)
1. Open the [web interface](http://owntone.local:3689)
2. Start Remote, go to Settings, Add Library
3. Enter the pair code in the web interface (reload the browser page if
it does not automatically pick up the pairing request)
If Remote doesn't connect to OwnTone after you entered the pairing code
If Remote does not connect to OwnTone after you entered the pairing code
something went wrong. Check the log file to see the error message. Here are
some common reasons:
- You did not enter the correct pairing code
You will see an error in the log about pairing failure with a HTTP response code
that is *not* 0.
You will see an error in the log about pairing failure with a HTTP response code
that is *not* 0.
Solution: Try again.
Solution: Try again.
- No response from Remote, possibly a network issue
If you see an error in the log with either:
If you see an error in the log with either:
- a HTTP response code that is 0
- "Empty pairing request callback"
- a HTTP response code that is 0
- "Empty pairing request callback"
it means that OwnTone could not establish a connection to Remote. This
might be a network issue, your router may not be allowing multicast between the
Remote device and the host OwnTone is running on.
it means that OwnTone could not establish a connection to Remote. This
might be a network issue, your router may not be allowing multicast between the
Remote device and the host OwnTone is running on.
Solution 1: Sometimes it resolves the issue if you force Remote to quit, restart
it and do the pairing process again. Another trick is to establish some other
connection (eg SSH) from the iPod/iPhone/iPad to the host.
Solution 1: Sometimes it resolves the issue if you force Remote to quit, restart
it and do the pairing process again. Another trick is to establish some other
connection (eg SSH) from the iPod/iPhone/iPad to the host.
Solution 2: Check your router settings if you can whitelist multicast addresses
under IGMP settings. For Apple Bonjour, setting a multicast address of
224.0.0.251 and a netmask of 255.255.255.255 should work.
Solution 2: Check your router settings if you can whitelist multicast addresses
under IGMP settings. For Apple Bonjour, setting a multicast address of
224.0.0.251 and a netmask of 255.255.255.255 should work.
- Otherwise try using avahi-browse for troubleshooting:
- in a terminal, run `avahi-browse -r -k _touch-remote._tcp`
- start Remote, goto Settings, Add Library
- after a couple seconds at most, you should get something similar to this:
- Otherwise try using `avahi-browse` for troubleshooting:
- in a terminal, run:
```shell
avahi-browse -r -k _touch-remote._tcp
```
- start Remote, goto Settings, Add Library
- after a couple seconds at most, you should get something similar to this:
```shell
+ ath0 IPv4 59eff13ea2f98dbbef6c162f9df71b784a3ef9a3 _touch-remote._tcp local
= ath0 IPv4 59eff13ea2f98dbbef6c162f9df71b784a3ef9a3 _touch-remote._tcp local
hostname = [Foobar.local]
@ -61,6 +66,6 @@ some common reasons:
txt = ["DvTy=iPod touch" "RemN=Remote" "txtvers=1" "RemV=10000" "Pair=FAEA410630AEC05E" "DvNm=Foobar"]
```
Hit Ctrl-C to terminate avahi-browse.
Hit Ctrl+C to terminate `avahi-browse`.
- To check for network issues you can try to connect to address and port with telnet.
- To check for network issues you can try to connect to the server address and port with [`nc`](https://en.wikipedia.org/wiki/Netcat) or [`telnet`](https://en.wikipedia.org/wiki/Telnet) commands.

View File

@ -1,4 +1,4 @@
# Supported clients
# Supported Clients
OwnTone supports these kinds of clients:
@ -22,21 +22,21 @@ connect without authentication. You can change that in the configuration file.
Here is a list of working and non-working DAAP and Remote clients. The list is
probably obsolete when you read it :-)
| Client | Developer | Type | Platform | Working (vers.) |
| ------------------------ | ----------- | ------ | ------------- | --------------- |
| iTunes | Apple | DAAP | Win | Yes (12.10.1) |
| Apple Music | Apple | DAAP | MacOS | Yes |
| Rhythmbox | Gnome | DAAP | Linux | Yes |
| Diapente | diapente | DAAP | Android | Yes |
| WinAmp DAAPClient | WardFamily | DAAP | WinAmp | Yes |
| Amarok w/DAAP plugin | KDE | DAAP | Linux/Win | Yes (2.8.0) |
| Banshee | | DAAP | Linux/Win/OSX | No (2.6.2) |
| jtunes4 | | DAAP | Java | No |
| Firefly Client | | (DAAP) | Java | No |
| Remote | Apple | Remote | iOS | Yes (4.3) |
| Retune | SquallyDoc | Remote | Android | Yes (3.5.23) |
| TunesRemote+ | Melloware | Remote | Android | Yes (2.5.3) |
| Remote for iTunes | Hyperfine | Remote | Android | Yes |
| Remote for Windows Phone | Komodex | Remote | Windows Phone | Yes (2.2.1.0) |
| TunesRemote SE | | Remote | Java | Yes (r108) |
| rtRemote for Windows | bizmodeller | Remote | Windows | Yes (1.2.0.67) |
| Client | Developer | Type | Platform | Working (vers.) |
| ------------------------ | ----------- | ------ | --------------- | --------------- |
| iTunes | Apple | DAAP | Win | Yes (12.10.1) |
| Apple Music | Apple | DAAP | macOS | Yes |
| Rhythmbox | Gnome | DAAP | Linux | Yes |
| Diapente | diapente | DAAP | Android | Yes |
| WinAmp DAAPClient | WardFamily | DAAP | WinAmp | Yes |
| Amarok w/DAAP plugin | KDE | DAAP | Linux/Win | Yes (2.8.0) |
| Banshee | | DAAP | Linux/Win/macOS | No (2.6.2) |
| jtunes4 | | DAAP | Java | No |
| Firefly Client | | (DAAP) | Java | No |
| Remote | Apple | Remote | iOS | Yes (4.3) |
| Retune | SquallyDoc | Remote | Android | Yes (3.5.23) |
| TunesRemote+ | Melloware | Remote | Android | Yes (2.5.3) |
| Remote for iTunes | Hyperfine | Remote | Android | Yes |
| Remote for Windows Phone | Komodex | Remote | Windows Phone | Yes (2.2.1.0) |
| TunesRemote SE | | Remote | Java | Yes (r108) |
| rtRemote for Windows | bizmodeller | Remote | Windows | Yes (1.2.0.67) |

1230
docs/configuration.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -7,10 +7,10 @@ hide:
# OwnTone
**OwnTone** is an open source (audio) media server for GNU/Linux, FreeBSD
and MacOS.
and macOS.
It allows sharing and streaming your media library to iTunes (DAAP[^1]),
Roku (RSP), AirPlay devices (multiroom), Chromecast and also supports local
Roku (RSP), AirPlay devices (multi-room), Chromecast and also supports local
playback.
You can control OwnTone via its web interface, Apple Remote (and compatible
@ -36,9 +36,9 @@ OwnTone is written in C with a web interface written in Vue.js.
- :material-volume-high: Local audio playback with ALSA or PulseAudio
- Supports multiple different clients:
- :material-cellphone: Remote apps like Apple Remote (iOS) or Retune (Android)
- :material-web: Integrated mobile friendly web interface
- :material-console: MPD clients
- :material-cellphone: Remote apps like Apple Remote (iOS) or Retune (Android)
- :material-web: Integrated mobile friendly web interface
- :material-console: MPD clients
- Supports :material-music: music and :material-book-open-variant:
audiobook files, :material-microphone: podcast files and :material-rss: RSS
@ -66,7 +66,7 @@ and what features it was built with (e.g. Spotify support).
How to find out? Go to the [web interface](http://owntone.local:3689) and
check. No web interface? Then check the top of OwnTone's log file (usually
/var/log/owntone.log).
`/var/log/owntone.log`).
Note that you are viewing a snapshot of the instructions that may or may not
match the version of OwnTone that you are using.
@ -78,4 +78,4 @@ please see the documentation on [Building from Source](installation.md).
You can find source and documentation, also for older versions, here:
- [https://github.com/owntone/owntone-server.git](https://github.com/owntone/owntone-server.git)
- [Source Code](https://github.com/owntone/owntone-server.git)

View File

@ -5,4 +5,3 @@ go to the web interface and authorize OwnTone with your LastFM credentials.
OwnTone will not store your LastFM username/password, only the session key.
The session key does not expire.

View File

@ -44,7 +44,7 @@ The easiest way of accomplishing this may be with [Spocon](https://github.com/sp
since it requires minimal configuration. After installing, create two pipes
(with mkfifo) and set the configuration in the player section:
```
```conf
# Audio output device (MIXER, PIPE, STDOUT)
output = "PIPE"
# Output raw (signed) PCM to this file (`player.output` must be PIPE)

View File

@ -3,12 +3,12 @@ hide:
- navigation
---
# OwnTone API Endpoint Reference
# API Endpoint Reference
Available API endpoints:
* [Player](#player): control playback, volume, shuffle/repeat modes
* [Outputs / Speakers](#outputs-speakers): list available outputs and enable/disable outputs
* [Outputs](#outputs): list available outputs and enable/disable outputs
* [Queue](#queue): list, add or modify the current queue
* [Library](#library): list playlists, artists, albums and tracks from your library or trigger library rescan
* [Search](#search): search for playlists, artists, albums and tracks
@ -18,10 +18,15 @@ Available API endpoints:
JSON-Object model:
* [Queue item](#queue-item-object)
* [Playlist](#playlist-object)
* [Artist](#artist-object)
* [Album](#album-object)
* [Artist](#artist-object)
* [Browse Info](#browse-info-object)
* [Category](#category-object)
* [Directory](#directory-object)
* [Option](#option-object)
* [Paging](#paging-object)
* [Playlist](#playlist-object)
* [Queue item](#queue-item-object)
* [Track](#track-object)
## Player
@ -37,8 +42,6 @@ JSON-Object model:
| PUT | [/api/player/volume](#set-volume) | Set master volume or volume for a specific output |
| PUT | [/api/player/seek](#seek) | Seek to a position in the currently playing track |
### Get player status
**Endpoint**
@ -60,7 +63,6 @@ GET /api/player
| item_length_ms | integer | Total length in milliseconds of the current queue item |
| item_progress_ms | integer | Progress into the current queue item in milliseconds |
**Example**
```shell
@ -80,7 +82,6 @@ curl -X GET "http://localhost:3689/api/player"
}
```
### Control playback
Start or resume, pause, stop playback.
@ -125,7 +126,6 @@ curl -X PUT "http://localhost:3689/api/player/stop"
curl -X PUT "http://localhost:3689/api/player/toggle"
```
### Skip tracks
Skip forward or backward
@ -154,7 +154,6 @@ curl -X PUT "http://localhost:3689/api/player/next"
curl -X PUT "http://localhost:3689/api/player/previous"
```
### Set shuffle mode
Enable or disable shuffle mode
@ -171,7 +170,6 @@ PUT /api/player/shuffle
| --------------- | ----------------------------------------------------------- |
| state | The new shuffle state, should be either `true` or `false` |
**Response**
On success returns the HTTP `204 No Content` success status response code.
@ -182,7 +180,6 @@ On success returns the HTTP `204 No Content` success status response code.
curl -X PUT "http://localhost:3689/api/player/shuffle?state=true"
```
### Set consume mode
Enable or disable consume mode
@ -199,7 +196,6 @@ PUT /api/player/consume
| --------------- | ----------------------------------------------------------- |
| state | The new consume state, should be either `true` or `false` |
**Response**
On success returns the HTTP `204 No Content` success status response code.
@ -210,7 +206,6 @@ On success returns the HTTP `204 No Content` success status response code.
curl -X PUT "http://localhost:3689/api/player/consume?state=true"
```
### Set repeat mode
Change repeat mode
@ -227,7 +222,6 @@ PUT /api/player/repeat
| --------------- | ----------------------------------------------------------- |
| state | The new repeat mode, should be either `off`, `all` or `single` |
**Response**
On success returns the HTTP `204 No Content` success status response code.
@ -238,7 +232,6 @@ On success returns the HTTP `204 No Content` success status response code.
curl -X PUT "http://localhost:3689/api/player/repeat?state=all"
```
### Set volume
Change master volume or volume of a specific output.
@ -255,7 +248,7 @@ PUT /api/player/volume
| --------------- | ----------------------------------------------------------- |
| volume | The new volume (0 - 100) |
| step | The increase or decrease volume by the given amount (-100 - 100) |
| output_id | *(Optional)* If an output id is given, only the volume of this output will be changed. If parameter is omited, the master volume will be changed. |
| output_id | *(Optional)* If an output id is given, only the volume of this output will be changed. If parameter is omitted, the master volume will be changed. |
Either `volume` or `step` must be present as query parameter
@ -277,7 +270,6 @@ curl -X PUT "http://localhost:3689/api/player/volume?step=-5"
curl -X PUT "http://localhost:3689/api/player/volume?volume=50&output_id=0"
```
### Seek
Seek to a position in the currently playing track.
@ -295,7 +287,6 @@ PUT /api/player/seek
| position_ms | The new position in milliseconds to seek to |
| seek_ms | A relative amount of milliseconds to seek to |
**Response**
On success returns the HTTP `204 No Content` success status response code.
@ -314,8 +305,7 @@ Relative seeking (skip 30 seconds backwards):
curl -X PUT "http://localhost:3689/api/player/seek?seek_ms=-30000"
```
## Outputs / Speakers
## Outputs
| Method | Endpoint | Description |
| --------- | ------------------------------------------------ | ------------------------------------ |
@ -325,8 +315,6 @@ curl -X PUT "http://localhost:3689/api/player/seek?seek_ms=-30000"
| PUT | [/api/outputs/{id}](#change-an-output) | Change an output setting |
| PUT | [/api/outputs/{id}/toggle](#toggle-an-output) | Enable or disable an output, depending on the current state |
### Get a list of available outputs
**Endpoint**
@ -354,7 +342,6 @@ GET /api/outputs
| needs_auth_key | boolean | `true` if output requires an authorization key (device verification) |
| volume | integer | Volume in percent (0 - 100) |
**Example**
```shell
@ -425,7 +412,6 @@ On success returns the HTTP `204 No Content` success status response code.
curl -X PUT "http://localhost:3689/api/outputs/set" --data "{\"outputs\":[\"198018693182577\",\"0\"]}"
```
### Get an output
Get an output
@ -525,8 +511,6 @@ On success returns the HTTP `204 No Content` success status response code.
curl -X PUT "http://localhost:3689/api/outputs/0/toggle"
```
## Queue
| Method | Endpoint | Description |
@ -537,8 +521,6 @@ curl -X PUT "http://localhost:3689/api/outputs/0/toggle"
| PUT | [/api/queue/items/{id}\|now_playing](#updating-a-queue-item)| Updating a queue item in the queue |
| DELETE | [/api/queue/items/{id}](#removing-a-queue-item) | Remove a queue item from the queue |
### List queue items
Lists the items in the current queue
@ -553,7 +535,7 @@ GET /api/queue
| Parameter | Value |
| --------------- | ----------------------------------------------------------- |
| id | *(Optional)* If a queue item id is given, only the item with the id will be returend. Use id=now_playing to get the currently playing item. |
| id | *(Optional)* If a queue item id is given, only the item with the id will be returned. Use id=now_playing to get the currently playing item. |
| start | *(Optional)* If a `start`and an `end` position is given, only the items from `start` (included) to `end` (excluded) will be returned. If only a `start` position is given, only the item at this position will be returned. |
| end | *(Optional)* See `start` parameter |
@ -602,7 +584,6 @@ curl -X GET "http://localhost:3689/api/queue"
}
```
### Clearing the queue
Remove all items form the current queue
@ -623,7 +604,6 @@ On success returns the HTTP `204 No Content` success status response code.
curl -X PUT "http://localhost:3689/api/queue/clear"
```
### Adding items to the queue
Add tracks, playlists artists or albums to the current queue
@ -644,10 +624,10 @@ POST /api/queue/items/add
| playback | *(Optional)* If the `playback` parameter is set to `start`, playback will be started after adding the new items. |
| playback_from_position | *(Optional)* If the `playback` parameter is set to `start`, playback will be started with the queue item at the position given in `playback_from_position`. |
| clear | *(Optional)* If the `clear` parameter is set to `true`, the queue will be cleared before adding the new items. |
| shuffle | *(Optional)* If the `shuffle` parameter is set to `true`, the shuffle mode is activated. If it is set to something else, the shuffle mode is deactivated. To leave the shuffle mode untouched the parameter should be ommited. |
| shuffle | *(Optional)* If the `shuffle` parameter is set to `true`, the shuffle mode is activated. If it is set to something else, the shuffle mode is deactivated. To leave the shuffle mode untouched the parameter should be omitted. |
| limit | *(Optional)* Maximum number of tracks to add |
Either the `uris` or the `expression` parameter must be set. If both are set the `uris` parameter takes presedence and the `expression` parameter will be ignored.
Either the `uris` or the `expression` parameter must be set. If both are set the `uris` parameter takes precedence and the `expression` parameter will be ignored.
**Response**
@ -659,7 +639,6 @@ On success returns the HTTP `200 OK` success status response code.
| count | integer | Number of tracks added to the queue |
| items | array | Array of [`queue item`](#queue-item-object) objects added |
**Example**
Add new items by uri:
@ -736,8 +715,9 @@ curl -X POST "http://localhost:3689/api/queue/items/add?expression=media_kind+is
}
```
Clear current queue, add 10 new random tracks of `genre` _Pop_ and start playback
```
Clear current queue, add 10 new random tracks of `genre` *Pop* and start playback
```shell
curl -X POST "http://localhost:3689/api/queue/items/add?limit=10&clear=true&playback=start&expression=genre+is+%22Pop%22+order+by+random+desc"
```
@ -756,7 +736,9 @@ Update or move a queue item in the current queue
```http
PUT /api/queue/items/{id}
```
or
```http
PUT /api/queue/items/now_playing
```
@ -767,7 +749,7 @@ PUT /api/queue/items/now_playing
| --------------- | -------------------- |
| id | Queue item id |
(or use now_playing to update the currenly playing track)
(or use now_playing to update the track currently playing)
**Query parameters**
@ -826,8 +808,6 @@ On success returns the HTTP `204 No Content` success status response code.
curl -X PUT "http://localhost:3689/api/queue/items/2"
```
## Library
| Method | Endpoint | Description |
@ -858,8 +838,6 @@ curl -X PUT "http://localhost:3689/api/queue/items/2"
| PUT | [/api/rescan](#trigger-metadata-rescan) | Trigger a library metadata rescan |
| PUT | [/api/library/backup](#backup-db) | Request library backup db |
### Library information
List some library stats
@ -882,7 +860,6 @@ GET /api/library
| updated_at | string | Last library update (timestamp in `ISO 8601` format) |
| updating | boolean | `true` if library rescan is in progress |
**Example**
```shell
@ -901,7 +878,6 @@ curl -X GET "http://localhost:3689/api/library"
}
```
### List playlists
Lists all playlists in your library (does not return playlist folders)
@ -928,7 +904,6 @@ GET /api/library/playlists
| offset | integer | Requested offset of the first playlist |
| limit | integer | Requested maximum number of playlists |
**Example**
```shell
@ -953,7 +928,6 @@ curl -X GET "http://localhost:3689/api/library/playlists"
}
```
### Get a playlist
Get a specific playlists in your library
@ -974,7 +948,6 @@ GET /api/library/playlists/{id}
On success returns the HTTP `200 OK` success status response code. With the response body holding the **[`playlist`](#playlist-object) object**.
**Example**
```shell
@ -991,7 +964,6 @@ curl -X GET "http://localhost:3689/api/library/playlists/1"
}
```
### Update a playlist
Update attributes of a specific playlists in your library
@ -1014,14 +986,12 @@ PUT /api/library/playlists/{id}
| --------------- | ----------------------------------------------------------- |
| query_limit | For RSS feeds, this sets how many podcasts to retrieve |
**Example**
```shell
curl -X PUT "http://localhost:3689/api/library/playlists/25?query_limit=20"
```
### Delete a playlist
Delete a playlist, e.g. a RSS feed
@ -1044,7 +1014,6 @@ DELETE /api/library/playlists/{id}
curl -X DELETE "http://localhost:3689/api/library/playlists/25"
```
### List playlist tracks
Lists the tracks in a playlists
@ -1077,7 +1046,6 @@ GET /api/library/playlists/{id}/tracks
| offset | integer | Requested offset of the first track |
| limit | integer | Requested maximum number of tracks |
**Example**
```shell
@ -1139,7 +1107,6 @@ PUT /api/library/playlists/{id}/tracks
| --------------- | ----------------------------------------------------------- |
| play_count | Either `increment`, `played` or `reset`. `increment` will increment `play_count` and update `time_played`, `played` will be like `increment` but only where `play_count` is 0, `reset` will set `play_count` and `skip_count` to zero and delete `time_played` and `time_skipped` |
**Example**
```shell
@ -1180,7 +1147,6 @@ GET /api/library/playlists/{id}/playlists
| offset | integer | Requested offset of the first playlist |
| limit | integer | Requested maximum number of playlist |
**Example**
```shell
@ -1215,7 +1181,6 @@ curl -X GET "http://localhost:3689/api/library/playlists/0/tracks"
}
```
### List artists
Lists the artists in your library
@ -1242,7 +1207,6 @@ GET /api/library/artists
| offset | integer | Requested offset of the first artist |
| limit | integer | Requested maximum number of artists |
**Example**
```shell
@ -1269,7 +1233,6 @@ curl -X GET "http://localhost:3689/api/library/artists"
}
```
### Get an artist
Get a specific artist in your library
@ -1290,7 +1253,6 @@ GET /api/library/artists/{id}
On success returns the HTTP `200 OK` success status response code. With the response body holding the **[`artist`](#artist-object) object**.
**Example**
```shell
@ -1309,7 +1271,6 @@ curl -X GET "http://localhost:3689/api/library/artists/3815427709949443149"
}
```
### List artist albums
Lists the albums of an artist
@ -1342,7 +1303,6 @@ GET /api/library/artists/{id}/albums
| offset | integer | Requested offset of the first album |
| limit | integer | Requested maximum number of albums |
**Example**
```shell
@ -1370,7 +1330,6 @@ curl -X GET "http://localhost:3689/api/library/artists/32561671101664759/albums"
}
```
### List albums
Lists the albums in your library
@ -1397,7 +1356,6 @@ GET /api/library/albums
| offset | integer | Requested offset of the first albums |
| limit | integer | Requested maximum number of albums |
**Example**
```shell
@ -1425,7 +1383,6 @@ curl -X GET "http://localhost:3689/api/library/albums"
}
```
### Get an album
Get a specific album in your library
@ -1446,7 +1403,6 @@ GET /api/library/albums/{id}
On success returns the HTTP `200 OK` success status response code. With the response body holding the **[`album`](#album-object) object**.
**Example**
```shell
@ -1466,7 +1422,6 @@ curl -X GET "http://localhost:3689/api/library/albums/8009851123233197743"
}
```
### List album tracks
Lists the tracks in an album
@ -1499,7 +1454,6 @@ GET /api/library/albums/{id}/tracks
| offset | integer | Requested offset of the first track |
| limit | integer | Requested maximum number of tracks |
**Example**
```shell
@ -1538,7 +1492,6 @@ curl -X GET "http://localhost:3689/api/library/albums/1/tracks"
}
```
### Get a track
Get a specific track in your library
@ -1559,7 +1512,6 @@ GET /api/library/tracks/{id}
On success returns the HTTP `200 OK` success status response code. With the response body holding the **[`track`](#track-object) object**.
**Example**
```shell
@ -1601,7 +1553,6 @@ curl -X GET "http://localhost:3689/api/library/tracks/1"
}
```
### List playlists for a track
Get the list of playlists that contain a track (does not return smart playlists)
@ -1658,7 +1609,6 @@ curl -X GET "http://localhost:3689/api/library/tracks/27/playlists"
}
```
### Update track properties
Change properties of one or more tracks (supported properties are "rating", "play_count" and "usermark")
@ -1675,7 +1625,6 @@ PUT /api/library/tracks
| --------------- | -------- | ----------------------- |
| tracks | array | Array of track objects |
**Response**
On success returns the HTTP `204 No Content` success status response code.
@ -1706,7 +1655,6 @@ PUT /api/library/tracks/{id}
| play_count | Either `increment` or `reset`. `increment` will increment `play_count` and update `time_played`, `reset` will set `play_count` and `skip_count` to zero and delete `time_played` and `time_skipped` |
| usermark | The new usermark (>= 0) |
**Response**
On success returns the HTTP `204 No Content` success status response code.
@ -1721,7 +1669,6 @@ curl -X PUT "http://localhost:3689/api/library/tracks/1?rating=100"
curl -X PUT "http://localhost:3689/api/library/tracks/1?play_count=increment"
```
### List genres
Get list of genres
@ -1731,6 +1678,7 @@ Get list of genres
```http
GET /api/library/genres
```
**Response**
| Key | Type | Value |
@ -1740,7 +1688,6 @@ GET /api/library/genres
| offset | integer | Requested offset of the first genre |
| limit | integer | Requested maximum number of genres |
**Example**
```shell
@ -1770,7 +1717,6 @@ curl -X GET "http://localhost:3689/api/library/genres"
"offset": 0,
"limit": -1
}
```
### List albums for genre
@ -1800,7 +1746,6 @@ GET api/search?type=albums&expression=genre+is+\"{genre name}\""
| offset | integer | Requested offset of the first albums |
| limit | integer | Requested maximum number of albums |
**Example**
```shell
@ -1877,7 +1822,6 @@ GET /api/library/count
| albums | integer | Number of albums matching the expression |
| db_playtime | integer | Total playtime in milliseconds of all tracks matching the expression |
**Example**
```shell
@ -1893,12 +1837,10 @@ curl -X GET "http://localhost:3689/api/library/count?expression=data_kind+is+fil
}
```
### List local directories
List the local directories and the directory contents (tracks and playlists)
**Endpoint**
```http
@ -1919,7 +1861,6 @@ GET /api/library/files
| tracks | object | [`paging`](#paging-object) object containing [`track`](#track-object) objects that matches the `directory` |
| playlists | object | [`paging`](#paging-object) object containing [`playlist`](#playlist-object) objects that matches the `directory` |
**Example**
```shell
@ -1994,7 +1935,7 @@ curl -X GET "http://localhost:3689/api/library/files?directory=/music/srv"
### Add an item to the library
This endpoint currently only supports addind RSS feeds.
This endpoint currently only supports adding RSS feeds.
**Endpoint**
@ -2052,7 +1993,7 @@ curl -X PUT "http://localhost:3689/api/update"
### Trigger metadata rescan
Trigger a library metadata rescan even if files have not been updated. Maintenence method.
Trigger a library metadata rescan even if files have not been updated. Maintenance method.
**Endpoint**
@ -2096,14 +2037,12 @@ curl -X PUT "http://localhost:3689/api/library/backup"
| Method | Endpoint | Description |
| --------- | ----------------------------------------------------------- | ------------------------------------ |
| GET | [/api/search](#search-by-search-term) | Search for playlists, artists, albums, tracks, composers by a simple search term |
| GET | [/api/search](#search-by-search-term) | Search for playlists, artists, albums, tracks, genres, composers by a simple search term |
| GET | [/api/search](#search-by-query-language) | Search by complex query expression |
### Search by search term
Search for playlists, artists, albums, tracks, composers that include the given query in their title (case insensitive matching).
Search for playlists, artists, albums, tracks, genres, composers that include the given query in their title (case insensitive matching).
**Endpoint**
@ -2116,7 +2055,7 @@ GET /api/search
| Parameter | Value |
| --------------- | ----------------------------------------------------------- |
| query | The search keyword |
| type | Comma separated list of the result types (`playlist`, `artist`, `album`, `track`, `composers`) |
| type | Comma separated list of the result types (`playlist`, `artist`, `album`, `track`, `genres`, `composers`) |
| media_kind | *(Optional)* Filter results by media kind (`music`, `movie`, `podcast`, `audiobook`, `musicvideo`, `tvshow`). Filter only applies to artist, album and track result types. |
| offset | *(Optional)* Offset of the first item to return for each type |
| limit | *(Optional)* Maximum number of items to return for each type |
@ -2125,12 +2064,12 @@ GET /api/search
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| tracks | object | [`paging`](#paging-object) object containing [`track`](#track-object) objects that matches the `query` |
| artists | object | [`paging`](#paging-object) object containing [`artist`](#artist-object) objects that matches the `query` |
| albums | object | [`paging`](#paging-object) object containing [`album`](#album-object) objects that matches the `query` |
| playlists | object | [`paging`](#paging-object) object containing [`playlist`](#playlist-object) objects that matches the `query` |
| composers | object | [`paging`](#paging-object) object containing `composers` objects that matches the `query` |
| tracks | object | [`paging`](#paging-object) object containing [`track`](#track-object) objects that match the `query` |
| artists | object | [`paging`](#paging-object) object containing [`artist`](#artist-object) objects that match the `query` |
| albums | object | [`paging`](#paging-object) object containing [`album`](#album-object) objects that match the `query` |
| playlists | object | [`paging`](#paging-object) object containing [`playlist`](#playlist-object) objects that match the `query` |
| genres | object | [`paging`](#paging-object) object containing [`browse-info`](#browse-info-object) objects that match the `query` |
| composers | object | [`paging`](#paging-object) object containing [`browse-info`](#browse-info-object) objects that match the `query` |
**Example**
@ -2272,7 +2211,6 @@ GET /api/search
| artists | object | [`paging`](#paging-object) object containing [`artist`](#artist-object) objects that matches the `query` |
| albums | object | [`paging`](#paging-object) object containing [`album`](#album-object) objects that matches the `query` |
**Example**
Search for music tracks ordered descending by the time added to the library and limit result to 2 items:
@ -2281,15 +2219,12 @@ Search for music tracks ordered descending by the time added to the library and
curl -X GET "http://localhost:3689/api/search?type=tracks&expression=media_kind+is+music+order+by+time_added+desc&offset=0&limit=2"
```
## Server info
| Method | Endpoint | Description |
| --------- | ------------------------------------------------ | ------------------------------------ |
| GET | [/api/config](#config) | Get configuration information |
### Config
**Endpoint**
@ -2306,7 +2241,6 @@ GET /api/config
| websocket_port | integer | Port number for the [websocket](#push-notifications) (or `0` if websocket is disabled) |
| buildoptions | array | Array of strings indicating which features are supported by the server |
**Example**
```shell
@ -2330,7 +2264,6 @@ curl -X GET "http://localhost:3689/api/config"
}
```
## Settings
| Method | Endpoint | Description |
@ -2341,8 +2274,6 @@ curl -X GET "http://localhost:3689/api/config"
| PUT | [/api/settings/{category-name}/{option-name}](#change-an-option-value) | Change the value of a setting option |
| DELETE | [/api/settings/{category-name}/{option-name}](#delete-an-option) | Reset a setting option to its default |
### List categories
List all settings categories with their options
@ -2359,7 +2290,6 @@ GET /api/settings
| --------------- | -------- | ----------------------------------------- |
| categories | array | Array of settings [category](#category-object) objects |
**Example**
```shell
@ -2388,7 +2318,6 @@ curl -X GET "http://localhost:3689/api/settings"
}
```
### Get a category
Get a settings category with their options
@ -2403,7 +2332,6 @@ GET /api/settings/{category-name}
Returns a settings [category](#category-object) object
**Example**
```shell
@ -2428,7 +2356,6 @@ curl -X GET "http://localhost:3689/api/settings/webinterface"
}
```
### Get an option
Get a single settings option
@ -2443,7 +2370,6 @@ GET /api/settings/{category-name}/{option-name}
Returns a settings [option](#option-object) object
**Example**
```shell
@ -2458,7 +2384,6 @@ curl -X GET "http://localhost:3689/api/settings/webinterface/show_composer_now_p
}
```
### Change an option value
Get a single settings option
@ -2480,14 +2405,12 @@ PUT /api/settings/{category-name}/{option-name}
On success returns the HTTP `204 No Content` success status response code.
**Example**
```shell
curl -X PUT "http://localhost:3689/api/settings/webinterface/show_composer_now_playing" --data "{\"name\":\"show_composer_now_playing\",\"value\":true}"
```
### Delete an option
Delete a single settings option (thus resetting it to default)
@ -2502,14 +2425,12 @@ DELETE /api/settings/{category-name}/{option-name}
On success returns the HTTP `204 No Content` success status response code.
**Example**
```shell
curl -X DELETE "http://localhost:3689/api/settings/webinterface/show_composer_now_playing"
```
## Push notifications
If the server was built with websocket support it exposes a websocket at `localhost:3688` to inform clients of changes (e. g. player state or library updates).
@ -2560,9 +2481,89 @@ curl --include \
}
```
## Objects
## Object model
### `album` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| id | string | Album id |
| name | string | Album name |
| name_sort | string | Album sort name |
| artist_id | string | Album artist id |
| artist | string | Album artist name |
| track_count | integer | Number of tracks |
| length_ms | integer | Total length of tracks in milliseconds |
| uri | string | Resource identifier |
| artwork_url | string | *(optional)* [Artwork url](#artwork-urls) |
### `artist` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| id | string | Artist id |
| name | string | Artist name |
| name_sort | string | Artist sort name |
| album_count | integer | Number of albums |
| track_count | integer | Number of tracks |
| length_ms | integer | Total length of tracks in milliseconds |
| uri | string | Resource identifier |
| artwork_url | string | *(optional)* [Artwork url](#artwork-urls) |
### `browse-info` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| name | string | Name (depends on the type of the query) |
| name_sort | string | Sort name |
| artist_count | integer | Number of artists |
| album_count | integer | Number of albums |
| track_count | integer | Number of tracks |
| time_played | string | Timestamp in `ISO 8601` format |
| time_added | string | Timestamp in `ISO 8601` format |
### `category` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| name | string | Category name |
| options | array | Array of option in this category |
### `directory` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| path | string | Directory path |
### `option` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| name | string | Option name |
| type | integer | The type of the value for this option (`0`: integer, `1`: boolean, `2`: string) |
| value | (integer / boolean / string) | Current value for this option |
### `paging` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| items | array | Array of result objects |
| total | integer | Total number of items |
| offset | integer | Requested offset of the first item |
| limit | integer | Requested maximum number of items |
### `playlist` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| id | string | Playlist id |
| name | string | Playlist name |
| path | string | Path |
| parent_id | integer | Playlist id of the parent (folder) playlist |
| type | string | Type of this playlist: `special`, `folder`, `smart`, `plain` |
| smart_playlist | boolean | `true` if playlist is a smart playlist |
| folder | boolean | `true` if it is a playlist folder |
| uri | string | Resource identifier |
### `queue item` object
@ -2596,50 +2597,6 @@ curl --include \
| samplerate | string | file sample rate (ie 44100/48000/...) |
| channel | string | file channel (ie mono/stereo/xx ch)) |
### `playlist` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| id | string | Playlist id |
| name | string | Playlist name |
| path | string | Path |
| parent_id | integer | Playlist id of the parent (folder) playlist |
| type | string | Type of this playlist: `special`, `folder`, `smart`, `plain` |
| smart_playlist | boolean | `true` if playlist is a smart playlist |
| folder | boolean | `true` if it is a playlist folder |
| uri | string | Resource identifier |
### `artist` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| id | string | Artist id |
| name | string | Artist name |
| name_sort | string | Artist sort name |
| album_count | integer | Number of albums |
| track_count | integer | Number of tracks |
| length_ms | integer | Total length of tracks in milliseconds |
| uri | string | Resource identifier |
| artwork_url | string | *(optional)* [Artwork url](#artwork-urls) |
### `album` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| id | string | Album id |
| name | string | Album name |
| name_sort | string | Album sort name |
| artist_id | string | Album artist id |
| artist | string | Album artist name |
| track_count | integer | Number of tracks |
| length_ms | integer | Total length of tracks in milliseconds |
| uri | string | Resource identifier |
| artwork_url | string | *(optional)* [Artwork url](#artwork-urls) |
### `track` object
| Key | Type | Value |
@ -2678,54 +2635,6 @@ curl --include \
| usermark | integer | User review marking of track (ranges from 0) |
| lyrics | string | The lyrics if found either as LRC or plain text |
### `paging` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| items | array | Array of result objects |
| total | integer | Total number of items |
| offset | integer | Requested offset of the first item |
| limit | integer | Requested maximum number of items |
### `browse-info` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| name | string | Name (depends on the type of the query) |
| name_sort | string | Sort name |
| artist_count | integer | Number of artists |
| album_count | integer | Number of albums |
| track_count | integer | Number of tracks |
| time_played | string | Timestamp in `ISO 8601` format |
| time_added | string | Timestamp in `ISO 8601` format |
### `directory` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| path | string | Directory path |
### `category` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| name | string | Category name |
| options | array | Array of option in this category |
### `option` object
| Key | Type | Value |
| --------------- | -------- | ----------------------------------------- |
| name | string | Option name |
| type | integer | The type of the value for this option (`0`: integer, `1`: boolean, `2`: string) |
| value | (integer / boolean / string) | Current value for this option |
### Artwork urls
Artwork urls in `queue item`, `artist`, `album` and `track` objects can be either relative urls or absolute urls to the artwork image.

View File

@ -23,7 +23,6 @@ directories only.
Files starting with . (dot) and _ (underscore) are ignored.
## Pipes (for e.g. multiroom with Shairport-sync)
Some programs, like for instance Shairport-sync, can be configured to output
@ -38,7 +37,7 @@ speakers you have selected (through Remote).
The format of the audio being written to the pipe must be PCM16.
You can also start playback of pipes manually. You will find them in remotes
You can also start playback of pipes manually. You will find them in remotes
listed under "Unknown artist" and "Unknown album". The track title will be the
name of the pipe.
@ -47,7 +46,6 @@ This requires that the metadata pipe has the same filename as the audio pipe
plus a ".metadata" suffix. Say Shairport-sync is configured to write audio to
"/foo/bar/pipe", then the metadata pipe should be "/foo/bar/pipe.metadata".
## Libraries on network mounts
Most network filesharing protocols do not offer notifications when the library
@ -57,13 +55,13 @@ Instead you can schedule a cron job to update the database.
The first step in doing this is to add two entries to the 'directories'
configuration item in owntone.conf:
```
```conf
directories = { "/some/local/dir", "/your/network/mount/library" }
```
Now you can make a cron job that runs this command:
```
```shell
touch /some/local/dir/trigger.init-rescan
```

View File

@ -1,11 +1,9 @@
# OwnTone smart playlists
# Smart Playlists
To add a smart playlist to the server, create a new text file with a filename ending with .smartpl;
To add a smart playlist to the server, create a new text file with a filename ending with .smartpl;
the filename doesn't matter, only the .smartpl ending does. The file must be placed somewhere in your
library folder.
## Syntax
The contents of a smart playlist must follow the syntax:
@ -16,7 +14,6 @@ The contents of a smart playlist must follow the syntax:
There is exactly one smart playlist allowed for a .smartpl file.
An expression consists of:
```
@ -82,10 +79,8 @@ Valid operands for the enumeration `media_kind` are:
* `audiobook`
* `tvshow`
Multiple expressions can be anded or ored together, using the keywords `OR` and `AND`. The unary not operator is also supported using the keyword `NOT`.
It is possible to define the sort order and limit the number of items by adding an order clause and/or a limit clause after the last expression:
```
@ -96,7 +91,6 @@ It is possible to define the sort order and limit the number of items by adding
There is additionally a special `random` _field-name_ that can be used in conjunction with `limit` to select a random number of items based on current expression.
## Examples
```
@ -144,6 +138,7 @@ This would match any podcast and audiobook file that was never played.
limit 10
}
```
This would match the last 10 music files added to the library.
```
@ -155,9 +150,10 @@ This would match the last 10 music files added to the library.
limit 10
}
```
This generates a random set of, maximum of 10, rated Pop music tracks every time the playlist is queried.
## Date operand syntax
## Date Operand Syntax
One example of a valid date is a date in yyyy-mm-dd format:
@ -178,7 +174,6 @@ As an example, a valid date might be:
```3 weeks before today``` or ```3 weeks ago```
Examples:
```
@ -202,13 +197,11 @@ All dates, except for `YYYY-DD-HH`, are relative to the day of when the server e
Note that `time_added after 4 weeks ago` and `time_added after last month` are subtly different; the former is exactly 4 weeks ago (from today) whereas the latter is the first day of the previous month.
## Differences with MT-daapd Smart Playlists
## Differences to mt-daapd smart playlists
The syntax is really close to the mt-daapd smart playlist syntax (see [Multi-Threaded DAAP Daemon Code](https://sourceforge.net/p/mt-daapd/code/HEAD/tree/tags/release-0.2.4.2/contrib/mt-daapd.playlist).
The syntax is really close to the mt-daapd smart playlist syntax (see
http://sourceforge.net/p/mt-daapd/code/HEAD/tree/tags/release-0.2.4.2/contrib/mt-daapd.playlist).
Even this documentation is based on the file linked above.
Even this documentation is based on the document linked above.
Some differences are:
@ -216,4 +209,3 @@ Some differences are:
* the not operator must be placed before an expression and not before the operator
* `||`, `&&`, `!` are not supported (use `or`, `and`, `not`)
* comments are not supported

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -431,6 +431,11 @@ mpd {
# clients and will need additional configuration in the MPD client to
# work). Set to 0 to disable serving artwork over http.
# http_port = 0
# The maximum size of a command list in KB.
# It is the sum of lengths of all the command lines between command list begin and end.
# Default is 2048 (2 MiB).
# max_command_list_size = KBYTES
}
# SQLite configuration (allows to modify the operation of the SQLite databases)

View File

@ -234,6 +234,7 @@ static cfg_opt_t sec_mpd[] =
{
CFG_INT("port", 6600, CFGF_NONE),
CFG_INT("http_port", 0, CFGF_NONE),
CFG_INT("max_command_list_size", 2048, CFGF_NONE),
CFG_BOOL("clear_queue_on_stop_disable", cfg_false, CFGF_NODEFAULT | CFGF_DEPRECATED),
CFG_BOOL("allow_modifying_stored_playlists", cfg_false, CFGF_NODEFAULT | CFGF_DEPRECATED),
CFG_STR("default_playlist_directory", NULL, CFGF_NODEFAULT | CFGF_DEPRECATED),

View File

@ -6000,6 +6000,65 @@ db_queue_move_bypos(int pos_from, int pos_to)
return ret;
}
int
db_queue_move_bypos_range(int range_begin, int range_end, int pos_to)
{
int queue_version;
char *query;
int ret;
int changes = 0;
queue_version = queue_transaction_begin();
int count = range_end - range_begin;
int update_begin = MIN(range_begin, pos_to);
int update_end = MAX(range_begin + count, pos_to + count);
int cut_off, offset_up, offset_down;
if (range_begin < pos_to) {
cut_off = range_begin + count;
offset_up = pos_to - range_begin;
offset_down = count;
} else {
cut_off = range_begin;
offset_up = count;
offset_down = range_begin - pos_to;
}
DPRINTF(E_DBG, L_DB, "db_queue_move_bypos_range: from = %d, to = %d,"
" count = %d, cut_off = %d, offset_up = %d, offset_down = %d,"
" begin = %d, end = %d\n",
range_begin, pos_to, count, cut_off, offset_up, offset_down, update_begin, update_end);
query = "UPDATE queue SET pos ="
" CASE"
" WHEN pos < :cut_off THEN pos + :offset_up"
" ELSE pos - :offset_down"
" END,"
" queue_version = :queue_version"
" WHERE"
" pos >= :update_begin AND pos < :update_end;";
sqlite3_stmt *stmt;
if (SQLITE_OK != (ret = sqlite3_prepare_v2(hdl, query, -1, &stmt, NULL))) goto end_transaction;
if (SQLITE_OK != (ret = sqlite3_bind_int(stmt, 1, cut_off))) goto end_transaction;
if (SQLITE_OK != (ret = sqlite3_bind_int(stmt, 2, offset_up))) goto end_transaction;
if (SQLITE_OK != (ret = sqlite3_bind_int(stmt, 3, offset_down))) goto end_transaction;
if (SQLITE_OK != (ret = sqlite3_bind_int(stmt, 4, queue_version))) goto end_transaction;
if (SQLITE_OK != (ret = sqlite3_bind_int(stmt, 5, update_begin))) goto end_transaction;
if (SQLITE_OK != (ret = sqlite3_bind_int(stmt, 6, update_end))) goto end_transaction;
changes = db_statement_run(stmt, 0);
end_transaction:
DPRINTF(E_LOG, L_DB, "db_queue_move_bypos_range: changes = %d, res = %d: %s\n",
changes, ret, sqlite3_errstr(ret));
queue_transaction_end(ret, queue_version);
return ret == SQLITE_OK && changes != -1 ? 0 : -1;
}
/*
* Moves the queue item at the given position to the given target position. The positions
* are relavtive to the given base item (item id).
@ -7093,8 +7152,9 @@ db_statements_prepare(void)
return 0;
}
// Returns -2 if backup not enabled in config
int
db_backup()
db_backup(void)
{
int ret;
sqlite3 *backup_hdl;
@ -7114,7 +7174,7 @@ db_backup()
if (realpath(db_path, resolved_dbp) == NULL || realpath(backup_path, resolved_bp) == NULL)
{
DPRINTF(E_LOG, L_DB, "Failed to resolve real path of db/backup path: %s\n", strerror(errno));
goto error;
return -1;
}
if (strcmp(resolved_bp, resolved_dbp) == 0)
@ -7129,14 +7189,15 @@ db_backup()
if (ret != SQLITE_OK)
{
DPRINTF(E_WARN, L_DB, "Failed to create backup '%s': %s\n", backup_path, sqlite3_errmsg(backup_hdl));
goto error;
return -1;
}
backup = sqlite3_backup_init(backup_hdl, "main", hdl, "main");
if (!backup)
{
DPRINTF(E_WARN, L_DB, "Failed to initiate backup '%s': %s\n", backup_path, sqlite3_errmsg(backup_hdl));
goto error;
sqlite3_close(backup_hdl);
return -1;
}
ret = sqlite3_backup_step(backup, -1);
@ -7148,10 +7209,7 @@ db_backup()
else
DPRINTF(E_WARN, L_DB, "Failed to complete backup '%s': %s (%d)\n", backup_path, sqlite3_errstr(ret), ret);
return ret;
error:
return -1;
return 0;
}
int

View File

@ -955,6 +955,9 @@ db_queue_move_byitemid(uint32_t item_id, int pos_to, char shuffle);
int
db_queue_move_bypos(int pos_from, int pos_to);
int
db_queue_move_bypos_range(int range_begin, int range_end, int pos_to);
int
db_queue_move_byposrelativetoitem(uint32_t from_pos, uint32_t to_offset, uint32_t item_id, char shuffle);
@ -1017,7 +1020,7 @@ int
db_watch_enum_fetchwd(struct watch_enum *we, uint32_t *wd);
int
db_backup();
db_backup(void);
int
db_perthread_init(void);

View File

@ -525,7 +525,7 @@ fetch_artist(bool *notfound, const char *artist_id)
if ((ret = db_query_fetch_group(&dbgri, &query_params)) == 0)
{
artist = artist_to_json(&dbgri);
notfound = false;
*notfound = false;
}
error:
@ -4326,6 +4326,68 @@ search_composers(json_object *reply, struct httpd_request *hreq, const char *par
return ret;
}
static int
search_genres(json_object *reply, struct httpd_request *hreq, const char *param_query, struct smartpl *smartpl_expression, enum media_kind media_kind)
{
json_object *type;
json_object *items;
struct query_params query_params;
int total;
int ret;
memset(&query_params, 0, sizeof(struct query_params));
ret = query_params_limit_set(&query_params, hreq);
if (ret < 0)
goto out;
type = json_object_new_object();
json_object_object_add(reply, "genres", type);
items = json_object_new_array();
json_object_object_add(type, "items", items);
query_params.type = Q_BROWSE_GENRES;
query_params.sort = S_GENRE;
ret = query_params_limit_set(&query_params, hreq);
if (ret < 0)
goto out;
if (param_query)
{
if (media_kind)
query_params.filter = db_mprintf("(f.genre LIKE '%%%q%%' AND f.media_kind = %d)", param_query, media_kind);
else
query_params.filter = db_mprintf("(f.genre LIKE '%%%q%%')", param_query);
}
else
{
query_params.filter = strdup(smartpl_expression->query_where);
query_params.having = safe_strdup(smartpl_expression->having);
query_params.order = safe_strdup(smartpl_expression->order);
if (smartpl_expression->limit > 0)
{
query_params.idx_type = I_SUB;
query_params.limit = smartpl_expression->limit;
query_params.offset = 0;
}
}
ret = fetch_browse_info(&query_params, items, &total);
if (ret < 0)
goto out;
json_object_object_add(type, "total", json_object_new_int(total));
json_object_object_add(type, "offset", json_object_new_int(query_params.offset));
json_object_object_add(type, "limit", json_object_new_int(query_params.limit));
out:
free_query_params(&query_params, 1);
return ret;
}
static int
search_playlists(json_object *reply, struct httpd_request *hreq, const char *param_query)
{
@ -4448,6 +4510,13 @@ jsonapi_reply_search(struct httpd_request *hreq)
goto error;
}
if (strstr(param_type, "genre"))
{
ret = search_genres(reply, hreq, param_query, &smartpl_expression, media_kind);
if (ret < 0)
goto error;
}
if (strstr(param_type, "playlist") && param_query)
{
ret = search_playlists(reply, hreq, param_query);

View File

@ -341,7 +341,7 @@ request_access_tokens(struct spotify_credentials *credentials, struct keyval *kv
}
/*
* Request the api endpoint at 'href' and retuns the response body as
* Request the api endpoint at 'href' and returns the response body as
* an allocated JSON object (must be freed by the caller) or NULL.
*
* @param href The spotify endpoint uri
@ -369,7 +369,7 @@ request_endpoint(const char *uri, const char *access_token)
goto out;
}
DPRINTF(E_DBG, L_SPOTIFY, "Request Spotify API endpoint: '%s')\n", uri);
DPRINTF(E_DBG, L_SPOTIFY, "Making request to '%s'\n", uri);
CHECK_ERR(L_SPOTIFY, pthread_mutex_lock(&spotify_http_session.lock));
ret = http_client_request(ctx, &spotify_http_session.session);
@ -396,7 +396,7 @@ request_endpoint(const char *uri, const char *access_token)
if (!json_response)
DPRINTF(E_LOG, L_SPOTIFY, "JSON parser returned an error for '%s'\n", uri);
else
DPRINTF(E_DBG, L_SPOTIFY, "Spotify API endpoint request: '%s'\n", uri);
DPRINTF(E_DBG, L_SPOTIFY, "Got JSON response for request to '%s'\n", uri);
out:
free_http_client_ctx(ctx);
@ -648,7 +648,7 @@ request_pagingobject_endpoint(const char *href, paging_item_cb item_cb, paging_r
ret = item_cb(item, (i + offset), total, request_type, arg, credentials);
if (ret < 0)
{
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: error processing item at index %d '%s' (API endpoint: '%s')\n",
DPRINTF(E_LOG, L_SPOTIFY, "Couldn't add item at index %d '%s' (API endpoint: '%s')\n",
i, json_object_to_json_string(item), href);
}
}

842
src/mpd.c

File diff suppressed because it is too large Load Diff

View File

@ -46,6 +46,7 @@
#include <json.h>
#include "conffile.h"
#include "misc.h"
#include "mdns.h"
#include "transcode.h"
#include "logger.h"

View File

@ -48,6 +48,7 @@
#define USE_CONST_AVCODEC (LIBAVFORMAT_VERSION_MAJOR > 59) || ((LIBAVFORMAT_VERSION_MAJOR == 59) && (LIBAVFORMAT_VERSION_MINOR > 15))
#define USE_NO_CLEAR_AVFMT_NOFILE (LIBAVFORMAT_VERSION_MAJOR > 59) || ((LIBAVFORMAT_VERSION_MAJOR == 59) && (LIBAVFORMAT_VERSION_MINOR > 15))
#define USE_CH_LAYOUT (LIBAVCODEC_VERSION_MAJOR > 59) || ((LIBAVCODEC_VERSION_MAJOR == 59) && (LIBAVCODEC_VERSION_MINOR > 24))
#define USE_CONST_AVIO_WRITE_PACKET (LIBAVFORMAT_VERSION_MAJOR > 61) || ((LIBAVFORMAT_VERSION_MAJOR == 61) && (LIBAVFORMAT_VERSION_MINOR > 0))
// Interval between ICY metadata checks for streams, in seconds
#define METADATA_ICY_INTERVAL 5
@ -887,8 +888,13 @@ avio_evbuffer_read(void *opaque, uint8_t *buf, int size)
return (ret > 0) ? ret : AVERROR_EOF;
}
#if USE_CONST_AVIO_WRITE_PACKET
static int
avio_evbuffer_write(void *opaque, const uint8_t *buf, int size)
#else
static int
avio_evbuffer_write(void *opaque, uint8_t *buf, int size)
#endif
{
struct avio_evbuffer *ae = (struct avio_evbuffer *)opaque;
int ret;

View File

@ -1,11 +0,0 @@
module.exports = {
env: {
node: true
},
extends: ['eslint:recommended', 'plugin:vue/vue3-recommended', 'prettier'],
rules: {
// Override/add rules settings here, such as:
'no-unused-vars': ['error', { args: 'none' }],
'vue/prop-name-casing': ['warn', 'snake_case']
}
}

45
web-src/eslint.config.js Normal file
View File

@ -0,0 +1,45 @@
import eslintConfigPrettier from 'eslint-config-prettier'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
export default [
{
files: ['src/**/*.js', 'src/**/.vue'],
languageOptions: {
globals: {
...globals.node
}
}
},
eslintConfigPrettier,
js.configs.all,
...pluginVue.configs['flat/recommended'],
{
rules: {
camelcase: 'off',
'consistent-this': 'off',
'id-length': 'off',
'max-lines': 'off',
'max-lines-per-function': 'off',
'max-statements': 'off',
'no-bitwise': 'off',
'no-magic-numbers': 'off',
'no-negated-condition': 'off',
'no-nested-ternary': 'off',
'no-plusplus': 'off',
'no-shadow': 'off',
'no-ternary': 'off',
'no-undef': 'off',
'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }],
'no-useless-assignment': 'off',
'one-var': 'off',
'prefer-named-capture-group': 'off',
'sort-keys': 'off',
'vue/html-self-closing': 'off',
'vue/max-attributes-per-line': 'off',
'vue/prop-name-casing': 'off',
'vue/singleline-html-element-content-newline': 'off'
}
}
]

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"scripts": {
"serve": "vite --port 3000",
"build": "vite build --base='./'",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src",
"lint": "eslint",
"dev": "vite",
"format": "prettier . --write",
"i18n:report": "vue-cli-service i18n:report --src \"./src/**/*.?(js|vue)\" --locales \"./src/i18n/**/*.json\"",
@ -15,29 +15,29 @@
"@aacassandra/vue3-progressbar": "^1.0.3",
"@mdi/js": "^7.4.47",
"@ts-pro/vue-eternal-loading": "^1.3.1",
"axios": "^1.6.5",
"axios": "^1.6.8",
"bulma": "^0.9.4",
"bulma-switch": "^2.0.4",
"luxon": "^3.4.4",
"mdi-vue": "^3.0.13",
"reconnectingwebsocket": "^1.0.0",
"spotify-web-api-js": "^1.5.2",
"vue": "^3.4.15",
"vue-i18n": "^9.9.0",
"vue-router": "^4.2.5",
"vue": "^3.4.23",
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.2",
"vue3-click-away": "^1.2.4",
"vue3-lazyload": "^0.3.8",
"vuedraggable": "^4.1.0",
"vuex": "^4.1.0"
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^2.0.0",
"@vitejs/plugin-vue": "^5.0.3",
"eslint": "^8.56.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@vitejs/plugin-vue": "^5.0.4",
"eslint": "^9.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.20.1",
"prettier": "^3.2.4",
"sass": "^1.70.0",
"vite": "^5.0.12"
"eslint-plugin-vue": "^9.25.0",
"prettier": "^3.2.5",
"sass": "^1.75.0",
"vite": "^5.2.10"
}
}

View File

@ -25,30 +25,30 @@
</template>
<script>
import NavbarTop from '@/components/NavbarTop.vue'
import NavbarBottom from '@/components/NavbarBottom.vue'
import NotificationList from '@/components/NotificationList.vue'
import * as types from '@/store/mutation_types'
import ModalDialogRemotePairing from '@/components/ModalDialogRemotePairing.vue'
import ModalDialogUpdate from '@/components/ModalDialogUpdate.vue'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
import NavbarBottom from '@/components/NavbarBottom.vue'
import NavbarTop from '@/components/NavbarTop.vue'
import NotificationList from '@/components/NotificationList.vue'
import ReconnectingWebSocket from 'reconnectingwebsocket'
import webapi from '@/webapi'
export default {
name: 'App',
components: {
NavbarTop,
NavbarBottom,
NotificationList,
ModalDialogRemotePairing,
ModalDialogUpdate
ModalDialogUpdate,
NavbarBottom,
NavbarTop,
NotificationList
},
data() {
return {
token_timer_id: 0,
pairing_active: false,
reconnect_attempts: 0,
pairing_active: false
token_timer_id: 0
}
},
@ -90,22 +90,18 @@ export default {
created() {
this.connect()
// Start the progress bar on app start
this.$Progress.start()
// Hook the progress bar to start before we move router-view
this.$router.beforeEach((to, from, next) => {
if (to.meta.show_progress && !(to.path === from.path && to.hash)) {
if (to.meta.progress !== undefined) {
const meta = to.meta.progress
this.$Progress.parseMeta(meta)
if (to.meta.progress) {
this.$Progress.parseMeta(to.meta.progress)
}
this.$Progress.start()
}
next()
})
// Hook the progress bar to finish after we've finished moving router-view
this.$router.afterEach((to, from) => {
if (to.meta.show_progress) {
@ -129,12 +125,11 @@ export default {
.catch(() => {
this.$store.dispatch('add_notification', {
text: this.$t('server.connection-failed'),
type: 'danger',
topic: 'connection'
topic: 'connection',
type: 'danger'
})
})
},
open_ws() {
if (this.$store.state.config.websocket_port <= 0) {
this.$store.dispatch('add_notification', {
@ -144,34 +139,29 @@ export default {
return
}
const vm = this
let protocol = 'ws://'
if (window.location.protocol === 'https:') {
protocol = 'wss://'
}
let wsUrl = `${protocol + window.location.hostname}:${
vm.$store.state.config.websocket_port
}`
let wsUrl = `${protocol}${window.location.hostname}:${this.$store.state.config.websocket_port}`
if (import.meta.env.DEV && import.meta.env.VITE_OWNTONE_URL) {
/*
* If we are running in development mode, construct the websocket
* url from the host of the environment variable VITE_OWNTONE_URL
*/
const owntoneUrl = new URL(import.meta.env.VITE_OWNTONE_URL)
wsUrl = `${protocol + owntoneUrl.hostname}:${
vm.$store.state.config.websocket_port
}`
const url = new URL(import.meta.env.VITE_OWNTONE_URL)
wsUrl = `${protocol}${url.hostname}:${this.$store.state.config.websocket_port}`
}
const socket = new ReconnectingWebSocket(wsUrl, 'notify', {
reconnectInterval: 1000,
maxReconnectInterval: 2000
maxReconnectInterval: 2000,
reconnectInterval: 1000
})
socket.onopen = function () {
const vm = this
socket.onopen = () => {
vm.reconnect_attempts = 0
socket.send(
JSON.stringify({
@ -189,7 +179,6 @@ export default {
]
})
)
vm.update_outputs()
vm.update_player_status()
vm.update_library_stats()
@ -208,11 +197,10 @@ export default {
*/
let update_throttled = false
function update_info() {
const update_info = () => {
if (update_throttled) {
return
}
vm.update_outputs()
vm.update_player_status()
vm.update_library_stats()
@ -221,7 +209,6 @@ export default {
vm.update_spotify()
vm.update_lastfm()
vm.update_pairing()
update_throttled = true
setTimeout(() => {
update_throttled = false
@ -239,7 +226,7 @@ export default {
}
})
socket.onmessage = function (response) {
socket.onmessage = (response) => {
const data = JSON.parse(response.data)
if (
data.notify.includes('update') ||
@ -271,7 +258,29 @@ export default {
}
}
},
update_is_clipped() {
if (this.show_burger_menu || this.show_player_menu) {
document.querySelector('html').classList.add('is-clipped')
} else {
document.querySelector('html').classList.remove('is-clipped')
}
},
update_outputs() {
webapi.outputs().then(({ data }) => {
this.$store.commit(types.UPDATE_OUTPUTS, data.outputs)
})
},
update_player_status() {
webapi.player_status().then(({ data }) => {
this.$store.commit(types.UPDATE_PLAYER_STATUS, data)
this.update_lyrics()
})
},
update_lastfm() {
webapi.lastfm().then(({ data }) => {
this.$store.commit(types.UPDATE_LASTFM, data)
})
},
update_library_stats() {
webapi.library_stats().then(({ data }) => {
this.$store.commit(types.UPDATE_LIBRARY_STATS, data)
@ -280,27 +289,6 @@ export default {
this.$store.commit(types.UPDATE_LIBRARY_RSS_COUNT, data)
})
},
update_outputs() {
webapi.outputs().then(({ data }) => {
this.$store.commit(types.UPDATE_OUTPUTS, data.outputs)
})
},
update_player_status() {
webapi.player_status().then(({ data }) => {
this.$store.commit(types.UPDATE_PLAYER_STATUS, data)
this.update_lyrics()
})
},
update_queue() {
webapi.queue().then(({ data }) => {
this.$store.commit(types.UPDATE_QUEUE, data)
this.update_lyrics()
})
},
update_lyrics() {
const track = this.$store.getters.now_playing
if (track && track.track_id) {
@ -311,19 +299,23 @@ export default {
this.$store.commit(types.UPDATE_LYRICS)
}
},
update_pairing() {
webapi.pairing().then(({ data }) => {
this.$store.commit(types.UPDATE_PAIRING, data)
this.pairing_active = data.active
})
},
update_queue() {
webapi.queue().then(({ data }) => {
this.$store.commit(types.UPDATE_QUEUE, data)
this.update_lyrics()
})
},
update_settings() {
webapi.settings().then(({ data }) => {
this.$store.commit(types.UPDATE_SETTINGS, data)
})
},
update_lastfm() {
webapi.lastfm().then(({ data }) => {
this.$store.commit(types.UPDATE_LASTFM, data)
})
},
update_spotify() {
webapi.spotify().then(({ data }) => {
this.$store.commit(types.UPDATE_SPOTIFY, data)
@ -339,21 +331,6 @@ export default {
)
}
})
},
update_pairing() {
webapi.pairing().then(({ data }) => {
this.$store.commit(types.UPDATE_PAIRING, data)
this.pairing_active = data.active
})
},
update_is_clipped() {
if (this.show_burger_menu || this.show_player_menu) {
document.querySelector('html').classList.add('is-clipped')
} else {
document.querySelector('html').classList.remove('is-clipped')
}
}
},
template: '<App/>'

View File

@ -1,15 +1,15 @@
<template>
<div
v-click-away="onClickOutside"
v-click-away="deactivate"
class="dropdown"
:class="{ 'is-active': is_active }"
:class="{ 'is-active': active }"
>
<div class="dropdown-trigger">
<button
class="button"
aria-haspopup="true"
aria-controls="dropdown"
@click="is_active = !is_active"
@click="active = !active"
>
<span v-text="option.name" />
<mdicon class="icon" name="chevron-down" size="16" />
@ -41,7 +41,7 @@ export default {
data() {
return {
is_active: false
active: false
}
},
@ -54,12 +54,11 @@ export default {
},
methods: {
onClickOutside(event) {
this.is_active = false
deactivate() {
this.active = false
},
select(option) {
this.is_active = false
this.active = false
this.$emit('update:value', option.id)
}
}

View File

@ -1,6 +1,6 @@
<template>
<figure>
<img v-lazy="{ src: artwork_url, lifecycle }" @click="$emit('click')" />
<img v-lazy="{ src: url, lifecycle }" @click="$emit('click')" />
</figure>
</template>
@ -12,7 +12,7 @@ export default {
props: {
album: { default: '', type: String },
artist: { default: '', type: String },
artwork_url: { default: '', type: String }
url: { default: '', type: String }
},
emits: ['click'],

View File

@ -6,8 +6,9 @@
:key="index"
class="button is-small"
:to="{ hash: `#index_${index}`, query: $route.query }"
>{{ index }}</router-link
>
{{ index }}
</router-link>
</nav>
</section>
</template>

View File

@ -10,7 +10,7 @@
<div v-else class="media is-align-items-center" @click="open(item.item)">
<div v-if="show_artwork" class="media-left">
<cover-artwork
:artwork_url="item.item.artwork_url"
:url="item.item.artwork_url"
:artist="item.item.artist"
:album="item.item.name"
class="is-clickable fd-has-shadow fd-cover fd-cover-small-image"
@ -47,6 +47,7 @@
@play-count-changed="play_count_changed()"
/>
<modal-dialog
:close_action="$t('page.podcast.cancel')"
:delete_action="$t('page.podcast.remove')"
:show="show_remove_podcast_modal"
:title="$t('page.podcast.remove-podcast')"
@ -120,7 +121,7 @@ export default {
},
open_remove_podcast_dialog() {
webapi
.library_album_tracks(this.selected_album.id, { limit: 1 })
.library_album_tracks(this.selected_item.id, { limit: 1 })
.then(({ data }) => {
webapi.library_track_playlists(data.items[0].id).then(({ data }) => {
;[this.rss_playlist_to_remove] = data.items.filter(

View File

@ -3,7 +3,7 @@
<div class="media is-align-items-center" @click="open(item)">
<div v-if="show_artwork" class="media-left is-clickable">
<cover-artwork
:artwork_url="artwork_url(item)"
:url="artwork_url(item)"
:artist="item.artist"
:album="item.name"
class="is-clickable fd-has-shadow fd-cover fd-cover-small-image"

View File

@ -1,14 +1,18 @@
<template>
<div
v-if="$route.query.directory"
class="media is-align-items-center"
@click="open_parent()"
>
<figure class="media-left is-clickable">
<mdicon class="icon" name="subdirectory-arrow-left" size="16" />
<div v-if="$route.query.directory" class="media is-align-items-center">
<figure class="media-left is-clickable" @click="open_parent">
<mdicon class="icon" name="chevron-left" size="16" />
</figure>
<div class="media-content is-clickable is-clipped">
<h1 class="title is-6">..</h1>
<div class="media-content is-clipped">
<nav class="breadcrumb">
<ul>
<li v-for="directory in directories" :key="directory.index">
<a @click="open(directory)">
<span v-text="directory.name" />
</a>
</li>
</ul>
</nav>
</div>
<div class="media-right">
<slot name="actions" />
@ -20,11 +24,7 @@
<mdicon class="icon" name="folder" size="16" />
</figure>
<div class="media-content is-clickable is-clipped">
<h1
class="title is-6"
v-text="item.path.substring(item.path.lastIndexOf('/') + 1)"
/>
<h2 class="subtitle is-7 has-text-grey" v-text="item.path" />
<h1 class="title is-6" v-text="item.name" />
</div>
<div class="media-right">
<a @click.prevent.stop="open_dialog(item)">
@ -58,40 +58,34 @@ export default {
},
computed: {
current() {
if (this.$route.query && this.$route.query.directory) {
return this.$route.query.directory
}
return '/'
directories() {
const directories = []
let path = ''
this.$route.query?.directory
.split('/')
.slice(1, -1)
.forEach((name, index) => {
path = `${path}/${name}`
directories.push({ index, name, path })
})
return directories
}
},
methods: {
open(item) {
this.$router.push({
name: 'files',
query: { directory: item.path }
})
const route = { name: 'files' }
if (item.index !== 0) {
route.query = { directory: item.path }
}
this.$router.push(route)
},
open_dialog(item) {
this.selected_item = item.path
this.show_details_modal = true
},
open_parent() {
const parent = this.current.slice(0, this.current.lastIndexOf('/'))
if (
parent === '' ||
this.$store.state.config.directories.includes(this.current)
) {
this.$router.push({ name: 'files' })
} else {
this.$router.push({
name: 'files',
query: {
directory: this.current.slice(0, this.current.lastIndexOf('/'))
}
})
}
this.open(this.directories.slice(-1).pop())
}
}
}

View File

@ -50,6 +50,44 @@ export default {
is_playing() {
return this.player.state === 'play'
},
lyrics() {
const raw = this.$store.state.lyrics.content
const parsed = []
if (raw) {
// Parse the lyrics
const regex = /(\[(\d+):(\d+)(?:\.\d+)?\] ?)?(.*)/u
raw.split('\n').forEach((item, index) => {
const matches = regex.exec(item)
if (matches && matches[4]) {
const verse = {
text: matches[4],
time: matches[2] * 60 + Number(matches[3])
}
parsed.push(verse)
}
})
// Split the verses into words
parsed.forEach((verse, index, lyrics) => {
const duration =
index < lyrics.length - 1 ? lyrics[index + 1].time - verse.time : 3
const unitDuration = duration / verse.text.length
let delay = 0
verse.words = verse.text.match(/\S+\s*/gu).map((text) => {
const duration = text.length * unitDuration
delay += duration
return {
duration,
delay,
text
}
})
})
}
return parsed
},
player() {
return this.$store.state.player
},
verse_index() {
if (this.lyrics.length && this.lyrics[0].time) {
const currentTime = this.player.item_progress_ms / 1000,
@ -68,17 +106,19 @@ export default {
(this.lastIndex < la.length - 1 &&
la[this.lastIndex + 1].time > currentTime) ||
this.lastIndex === la.length - 1
)
) {
return this.lastIndex
}
if (
this.lastIndex < la.length - 2 &&
la[this.lastIndex + 2].time > currentTime
)
) {
return this.lastIndex + 1
}
// Not found, then start a binary search
let start = 0,
end = la.length - 1,
index
let end = la.length - 1,
index = 0,
start = 0
while (start <= end) {
index = (start + end) >> 1
const currentVerse = la[index]
@ -89,81 +129,35 @@ export default {
) {
return index
}
currentVerse.time < currentTime
? (start = index + 1)
: (end = index - 1)
if (currentVerse.time < currentTime) {
start = index + 1
} else {
end = index - 1
}
}
return -1
} else {
this.reset_scrolling()
return -1
}
},
lyrics() {
const raw = this.$store.state.lyrics.content
const parsed = []
if (raw) {
// Parse the lyrics
const regex = /(\[(\d+):(\d+)(?:\.\d+)?\] ?)?(.*)/
raw.split('\n').forEach((item, index) => {
const matches = regex.exec(item)
if (matches && matches[4]) {
const verse = {
text: matches[4],
time: matches[2] * 60 + matches[3] * 1
}
parsed.push(verse)
}
})
// Split the verses into words
parsed.forEach((verse, index, lyrics) => {
const duration =
index < lyrics.length - 1 ? lyrics[index + 1].time - verse.time : 3
const unitDuration = duration / verse.text.length
let delay = 0
verse.words = verse.text.match(/\S+\s*/g).map((text) => {
const duration = text.length * unitDuration
delay += duration
return {
duration,
delay,
text
}
})
})
}
return parsed
},
player() {
return this.$store.state.player
this.reset_scrolling()
return -1
}
},
watch: {
verse_index() {
this.autoScrolling && this.scroll_to_verse()
if (this.autoScrolling) {
this.scroll_to_verse()
}
this.lastIndex = this.verse_index
}
},
methods: {
reset_scrolling() {
// Scroll to the start of the lyrics in all cases
if (this.player.item_id != this.lastItemId && this.$refs.lyrics) {
if (this.player.item_id !== this.lastItemId && this.$refs.lyrics) {
this.$refs.lyrics.scrollTo(0, 0)
}
this.lastItemId = this.player.item_id
this.lastIndex = -1
},
start_scrolling(e) {
// Consider only user events
if (e.screenX || e.screenX != 0 || e.screenY || e.screenY != 0) {
this.autoScrolling = false
if (this.scrollingTimer) {
clearTimeout(this.scrollingTimer)
}
// Reenable automatic scrolling after 2 seconds
this.scrollingTimer = setTimeout((this.autoScrolling = true), 2000)
}
},
scroll_to_verse() {
const pane = this.$refs.lyrics
if (this.verse_index === -1) {
@ -180,6 +174,17 @@ export default {
(currentVerse.offsetHeight >> 1) -
pane.scrollTop
})
},
start_scrolling(e) {
// Consider only user events
if (e.screenX || e.screenX !== 0 || e.screenY || e.screenY !== 0) {
this.autoScrolling = false
if (this.scrollingTimer) {
clearTimeout(this.scrollingTimer)
}
// Reenable automatic scrolling after 2 seconds
this.scrollingTimer = setTimeout((this.autoScrolling = true), 2000)
}
}
}
}

View File

@ -45,7 +45,7 @@
export default {
name: 'ModalDialog',
props: {
close_action: { default: 'dialog.cancel', type: String },
close_action: { default: '', type: String },
delete_action: { default: '', type: String },
ok_action: { default: '', type: String },
show: Boolean,

View File

@ -13,10 +13,11 @@
v-model="url"
class="input is-shadowless"
type="url"
pattern="http[s]?://.*"
pattern="http[s]?://.+"
required
:placeholder="$t('dialog.add.rss.placeholder')"
:disabled="loading"
@input="check_url"
/>
<mdicon class="icon is-left" name="rss" size="16" />
</p>
@ -38,6 +39,7 @@
<span class="is-size-7" v-text="$t('dialog.add.rss.cancel')" />
</a>
<a
:class="{ 'is-disabled': disabled }"
class="card-footer-item has-background-info has-text-white has-text-weight-bold"
@click="add_stream"
>
@ -66,6 +68,7 @@ export default {
data() {
return {
disabled: true,
loading: false,
url: ''
}
@ -75,7 +78,7 @@ export default {
show() {
if (this.show) {
this.loading = false
// We need to delay setting the focus to the input field until the field is part of the dom and visible
// Delay setting the focus on the input field until it is part of the DOM and visible
setTimeout(() => {
this.$refs.url_field.focus()
}, 10)
@ -96,6 +99,10 @@ export default {
.catch(() => {
this.loading = false
})
},
check_url(event) {
const { validity } = event.target
this.disabled = validity.patternMismatch || validity.valueMissing
}
}
}

View File

@ -13,10 +13,11 @@
v-model="url"
class="input is-shadowless"
type="url"
pattern="http[s]?://.*"
pattern="http[s]?://.+"
required
:placeholder="$t('dialog.add.stream.placeholder')"
:disabled="loading"
@input="check_url"
/>
<mdicon class="icon is-left" name="web" size="16" />
</p>
@ -36,11 +37,16 @@
<mdicon class="icon" name="cancel" size="16" />
<span class="is-size-7" v-text="$t('dialog.add.stream.cancel')" />
</a>
<a class="card-footer-item has-text-dark" @click="add_stream">
<a
:class="{ 'is-disabled': disabled }"
class="card-footer-item has-text-dark"
@click="add_stream"
>
<mdicon class="icon" name="playlist-plus" size="16" />
<span class="is-size-7" v-text="$t('dialog.add.stream.add')" />
</a>
<a
:class="{ 'is-disabled': disabled }"
class="card-footer-item has-background-info has-text-white has-text-weight-bold"
@click="play"
>
@ -69,6 +75,7 @@ export default {
data() {
return {
disabled: true,
loading: false,
url: ''
}
@ -78,8 +85,7 @@ export default {
show() {
if (this.show) {
this.loading = false
// We need to delay setting the focus to the input field until the field is part of the dom and visible
// Delay setting the focus on the input field until it is part of the DOM and visible
setTimeout(() => {
this.$refs.url_field.focus()
}, 10)
@ -100,7 +106,10 @@ export default {
this.loading = false
})
},
check_url(event) {
const { validity } = event.target
this.disabled = validity.patternMismatch || validity.valueMissing
},
play() {
this.loading = true
webapi

View File

@ -6,7 +6,7 @@
<div class="card">
<div class="card-content">
<cover-artwork
:artwork_url="item.artwork_url"
:url="item.artwork_url"
:artist="item.artist"
:album="item.name"
class="fd-has-shadow fd-cover fd-cover-normal-image mb-5"
@ -134,7 +134,7 @@ export default {
mark_played() {
webapi
.library_album_track_update(this.item.id, { play_count: 'played' })
.then(({ data }) => {
.then(() => {
this.$emit('play-count-changed')
this.$emit('close')
})

View File

@ -6,7 +6,7 @@
<div class="card">
<div class="card-content">
<cover-artwork
:artwork_url="artwork_url(item)"
:url="artwork_url(item)"
:artist="item.artist"
:album="item.name"
class="fd-has-shadow fd-cover fd-cover-normal-image mb-5"

View File

@ -3,24 +3,25 @@
<div v-if="show" class="modal is-active">
<div class="modal-background" @click="$emit('close')" />
<div class="modal-content">
<div class="card">
<form class="card" @submit.prevent="save">
<div class="card-content">
<p class="title is-4" v-text="$t('dialog.playlist.save.title')" />
<form class="mb-5" @submit.prevent="save">
<div class="field">
<p class="control has-icons-left">
<input
ref="playlist_name_field"
v-model="playlist_name"
class="input is-shadowless"
type="text"
:placeholder="$t('dialog.playlist.save.playlist-name')"
:disabled="loading"
/>
<mdicon class="icon is-left" name="file-music" size="16" />
</p>
</div>
</form>
<div class="field">
<p class="control has-icons-left">
<input
ref="playlist_name_field"
v-model="playlist_name"
class="input is-shadowless"
type="text"
pattern=".+"
required
:placeholder="$t('dialog.playlist.save.playlist-name')"
:disabled="loading"
@input="check_name"
/>
<mdicon class="icon is-left" name="file-music" size="16" />
</p>
</div>
</div>
<footer v-if="loading" class="card-footer">
<a class="card-footer-item has-text-dark">
@ -40,6 +41,7 @@
/>
</a>
<a
:class="{ 'is-disabled': disabled }"
class="card-footer-item has-background-info has-text-white has-text-weight-bold"
@click="save"
>
@ -50,7 +52,7 @@
/>
</a>
</footer>
</div>
</form>
</div>
<button
class="modal-close is-large"
@ -71,6 +73,7 @@ export default {
data() {
return {
disabled: true,
playlist_name: '',
loading: false
}
@ -80,8 +83,7 @@ export default {
show() {
if (this.show) {
this.loading = false
// We need to delay setting the focus to the input field until the field is part of the dom and visible
// Delay setting the focus on the input field until it is part of the DOM and visible
setTimeout(() => {
this.$refs.playlist_name_field.focus()
}, 10)
@ -90,11 +92,11 @@ export default {
},
methods: {
check_name(event) {
const { validity } = event.target
this.disabled = validity.patternMismatch || validity.valueMissing
},
save() {
if (this.playlist_name.length < 1) {
return
}
this.loading = true
webapi
.queue_save_playlist(this.playlist_name)

View File

@ -14,7 +14,7 @@
ref="pin_field"
v-model="pairing_req.pin"
class="input"
type="text"
inputmode="numeric"
pattern="[\d]{4}"
:placeholder="$t('dialog.remote-pairing.pairing-code')"
/>
@ -76,8 +76,7 @@ export default {
show() {
if (this.show) {
this.loading = false
// We need to delay setting the focus to the input field until the field is part of the dom and visible
// Delay setting the focus on the input field until it is part of the DOM and visible
setTimeout(() => {
this.$refs.pin_field.focus()
}, 10)

View File

@ -60,7 +60,8 @@
'is-loading': loading
}"
@click="togglePlay"
><mdicon class="icon" name="broadcast" size="18" />
>
<mdicon class="icon" name="broadcast" size="18" />
</a>
</div>
<div class="level-item">
@ -70,8 +71,8 @@
:class="{ 'has-text-grey-light': !playing }"
>
<p class="heading" v-text="$t('navigation.stream')" />
<a href="stream.mp3" class="heading ml-2" target="_blank"
><mdicon
<a href="stream.mp3" class="heading ml-2" target="_blank">
<mdicon
class="icon is-small"
name="open-in-new"
size="16"
@ -218,7 +219,8 @@
'is-loading': loading
}"
@click="togglePlay"
><mdicon class="icon" name="radio-tower" size="16" />
>
<mdicon class="icon" name="radio-tower" size="16" />
</a>
</div>
<div class="level-item">
@ -228,8 +230,8 @@
:class="{ 'has-text-grey-light': !playing }"
>
<p class="heading" v-text="$t('navigation.stream')" />
<a href="stream.mp3" class="heading ml-2" target="_blank"
><mdicon
<a href="stream.mp3" class="heading ml-2" target="_blank">
<mdicon
class="icon is-small"
name="open-in-new"
size="16"
@ -367,19 +369,19 @@ export default {
},
setupAudio() {
const a = audio.setup()
a.addEventListener('waiting', (e) => {
a.addEventListener('waiting', () => {
this.playing = false
this.loading = true
})
a.addEventListener('playing', (e) => {
a.addEventListener('playing', () => {
this.playing = true
this.loading = false
})
a.addEventListener('ended', (e) => {
a.addEventListener('ended', () => {
this.playing = false
this.loading = false
})
a.addEventListener('error', (e) => {
a.addEventListener('error', () => {
this.closeAudio()
this.$store.dispatch('add_notification', {
text: this.$t('navigation.stream-error'),

View File

@ -94,17 +94,17 @@
<b v-text="$t('navigation.search')" />
</navbar-item-link>
<hr class="my-3" />
<navbar-item-link :to="{ name: 'settings-webinterface' }">{{
$t('navigation.settings')
}}</navbar-item-link>
<navbar-item-link :to="{ name: 'settings-webinterface' }">
{{ $t('navigation.settings') }}
</navbar-item-link>
<a
class="navbar-item"
@click.stop.prevent="open_update_dialog()"
v-text="$t('navigation.update-library')"
/>
<navbar-item-link :to="{ name: 'about' }">{{
$t('navigation.about')
}}</navbar-item-link>
<navbar-item-link :to="{ name: 'about' }">
{{ $t('navigation.about') }}
</navbar-item-link>
</div>
</div>
</div>
@ -134,7 +134,7 @@ export default {
computed: {
search_name: {
get() {
return `search-${this.$store.state.search_source}`
return this.$store.state.search_source
}
},
show_audiobooks() {

View File

@ -13,7 +13,7 @@
<input
ref="setting"
class="column input is-one-fifth"
type="number"
inputmode="numeric"
min="0"
:placeholder="placeholder"
:value="value"
@ -84,29 +84,26 @@ export default {
clear_status() {
this.statusUpdate = ''
},
set_update_timer() {
set_update_timer(event) {
event.target.value = event.target.value.replace(/[^0-9]/gu, '')
if (this.timerId > 0) {
window.clearTimeout(this.timerId)
this.timerId = -1
}
this.statusUpdate = ''
const newValue = this.$refs.setting.value
if (newValue !== this.value) {
this.timerId = window.setTimeout(this.update_setting, this.timerDelay)
}
this.timerId = window.setTimeout(this.update_setting, this.timerDelay)
},
update_setting() {
this.timerId = -1
const newValue = this.$refs.setting.value
if (newValue === this.value) {
const newValue = parseInt(this.$refs.setting.value, 10)
if (isNaN(newValue) || newValue === this.value) {
this.statusUpdate = ''
return
}
const option = {
category: this.category.name,
name: this.option_name,
value: parseInt(newValue, 10)
value: newValue
}
webapi
.settings_update(this.category.name, option)

View File

@ -17,7 +17,10 @@
name="account-music"
size="16"
/>
<span v-text="$t('page.audiobooks.tabs.authors')" />
<span
class="is-hidden-mobile"
v-text="$t('page.audiobooks.tabs.authors')"
/>
</a>
</li>
</router-link>
@ -29,7 +32,10 @@
<li :class="{ 'is-active': isActive }">
<a @click="navigate" @keypress.enter="navigate">
<mdicon class="icon is-small" name="album" size="16" />
<span v-text="$t('page.audiobooks.tabs.audiobooks')" />
<span
class="is-hidden-mobile"
v-text="$t('page.audiobooks.tabs.audiobooks')"
/>
</a>
</li>
</router-link>
@ -41,7 +47,10 @@
<li :class="{ 'is-active': isActive }">
<a @click="navigate" @keypress.enter="navigate">
<mdicon class="icon is-small" name="speaker" size="16" />
<span v-text="$t('page.audiobooks.tabs.genres')" />
<span
class="is-hidden-mobile"
v-text="$t('page.audiobooks.tabs.genres')"
/>
</a>
</li>
</router-link>

View File

@ -13,7 +13,10 @@
<li :class="{ 'is-active': isActive }">
<a @click="navigate" @keypress.enter="navigate">
<mdicon class="icon is-small" name="history" size="16" />
<span v-text="$t('page.music.tabs.history')" />
<span
class="is-hidden-mobile"
v-text="$t('page.music.tabs.history')"
/>
</a>
</li>
</router-link>
@ -29,7 +32,10 @@
name="account-music"
size="16"
/>
<span v-text="$t('page.music.tabs.artists')" />
<span
class="is-hidden-mobile"
v-text="$t('page.music.tabs.artists')"
/>
</a>
</li>
</router-link>
@ -41,7 +47,10 @@
<li :class="{ 'is-active': isActive }">
<a @click="navigate" @keypress.enter="navigate">
<mdicon class="icon is-small" name="album" size="16" />
<span v-text="$t('page.music.tabs.albums')" />
<span
class="is-hidden-mobile"
v-text="$t('page.music.tabs.albums')"
/>
</a>
</li>
</router-link>
@ -53,7 +62,10 @@
<li :class="{ 'is-active': isActive }">
<a @click="navigate" @keypress.enter="navigate">
<mdicon class="icon is-small" name="speaker" size="16" />
<span v-text="$t('page.music.tabs.genres')" />
<span
class="is-hidden-mobile"
v-text="$t('page.music.tabs.genres')"
/>
</a>
</li>
</router-link>
@ -69,7 +81,10 @@
name="book-open-page-variant"
size="16"
/>
<span v-text="$t('page.music.tabs.composers')" />
<span
class="is-hidden-mobile"
v-text="$t('page.music.tabs.composers')"
/>
</a>
</li>
</router-link>
@ -82,7 +97,10 @@
<li :class="{ 'is-active': isActive }">
<a @click="navigate" @keypress.enter="navigate">
<mdicon class="icon is-small" name="spotify" size="16" />
<span v-text="$t('page.music.tabs.spotify')" />
<span
class="is-hidden-mobile"
v-text="$t('page.music.tabs.spotify')"
/>
</a>
</li>
</router-link>

View File

@ -7,20 +7,20 @@
<ul>
<li
:class="{
'is-active': $store.state.search_source === 'library'
'is-active': $route.name === 'search-library'
}"
>
<a @click="search_library">
<a @click="$emit('search-library')">
<mdicon class="icon is-small" name="bookshelf" size="16" />
<span v-text="$t('page.search.tabs.library')" />
</a>
</li>
<li
:class="{
'is-active': $store.state.search_source === 'spotify'
'is-active': $route.name === 'search-spotify'
}"
>
<a @click="search_spotify">
<a @click="$emit('search-spotify')">
<mdicon class="icon is-small" name="spotify" size="16" />
<span v-text="$t('page.search.tabs.spotify')" />
</a>
@ -34,45 +34,14 @@
</template>
<script>
import * as types from '@/store/mutation_types'
export default {
name: 'TabsSearch',
props: { query: { default: '', type: String } },
emits: ['search-library', 'search-spotify'],
computed: {
route_query() {
if (this.query) {
return {
limit: 3,
offset: 0,
query: this.query,
type: 'track,artist,album,composer,playlist,audiobook,podcast'
}
}
return null
},
spotify_enabled() {
return this.$store.state.spotify.webapi_token_valid
}
},
methods: {
search_library() {
this.$store.commit(types.SEARCH_SOURCE, 'library')
this.$router.push({
name: 'search-library',
query: this.route_query
})
},
search_spotify() {
this.$store.commit(types.SEARCH_SOURCE, 'spotify')
this.$router.push({
name: 'search-spotify',
query: this.route_query
})
}
}
}
</script>

View File

@ -8,7 +8,6 @@
}
},
"dialog": {
"cancel": "Abbrechen",
"add": {
"rss": {
"add": "Hinzufügen",
@ -385,6 +384,7 @@
"count": "{count} Playlist|{count} Playlisten"
},
"podcast": {
"cancel": "Abbrechen",
"play": "Spielen",
"remove": "Entfernen",
"remove-info-1": "Diesen Podcast wirklich dauerhaft aus der Bibliothek löschen?",

View File

@ -8,7 +8,6 @@
}
},
"dialog": {
"cancel": "Cancel",
"add": {
"rss": {
"add": "Add",
@ -385,6 +384,7 @@
"count": "{count} playlist|{count} playlist|{count} playlists"
},
"podcast": {
"cancel": "Cancel",
"play": "Play",
"remove": "Remove",
"remove-info-1": "Permanently remove this podcast from your library?",

View File

@ -8,7 +8,6 @@
}
},
"dialog": {
"cancel": "Annuler",
"add": {
"rss": {
"add": "Ajouter",
@ -385,6 +384,7 @@
"count": "{count} liste de lecture|{count} liste de lecture|{count} listes de lecture"
},
"podcast": {
"cancel": "Annuler",
"play": "Lire",
"remove": "Supprimer",
"remove-info-1": "Supprimer ce podcast de manière permanente de la bibliothèque ?",

View File

@ -8,7 +8,6 @@
}
},
"dialog": {
"cancel": "取消",
"add": {
"rss": {
"add": "添加",
@ -385,6 +384,7 @@
"count": "{count} 个播放列表|{count} 个播放列表"
},
"podcast": {
"cancel": "取消",
"play": "播放",
"remove": "移除",
"remove-info-1": "从资料库中永久移除该播客?",

View File

@ -12,6 +12,7 @@ import {
mdiCellphone,
mdiCheck,
mdiChevronDown,
mdiChevronLeft,
mdiChevronUp,
mdiContentSave,
mdiDelete,
@ -57,7 +58,6 @@ import {
mdiSpeaker,
mdiSpotify,
mdiStop,
mdiSubdirectoryArrowLeft,
mdiVolumeHigh,
mdiVolumeOff,
mdiWeb
@ -77,6 +77,7 @@ export const icons = {
mdiCellphone,
mdiCheck,
mdiChevronDown,
mdiChevronLeft,
mdiChevronUp,
mdiContentSave,
mdiDelete,
@ -122,7 +123,6 @@ export const icons = {
mdiSpeaker,
mdiSpotify,
mdiStop,
mdiSubdirectoryArrowLeft,
mdiVolumeHigh,
mdiVolumeOff,
mdiWeb

View File

@ -8,8 +8,8 @@ const stringComparator = (a, b) => a.localeCompare(b, locale.value)
const dateComparator = (a, b) =>
new Date(a) - new Date(b) || (!a ? -1 : !b ? 1 : 0)
function createComparators(criteria) {
return criteria.map(({ field, type, order = 1 }) => {
const createComparators = (criteria) =>
criteria.map(({ field, type, order = 1 }) => {
switch (type) {
case String:
return (a, b) => stringComparator(a[field], b[field]) * order
@ -17,9 +17,10 @@ function createComparators(criteria) {
return (a, b) => numberComparator(a[field], b[field]) * order
case Date:
return (a, b) => dateComparator(a[field], b[field]) * order
default:
return (a, b) => 0
}
})
}
const characterIndex = (string = '') => {
const value = string.charAt(0)
@ -31,8 +32,8 @@ const characterIndex = (string = '') => {
return '⌘'
}
export const numberIndex = (number) => {
return Math.floor(number / 10)
const numberIndex = (number) => {
Math.floor(number / 10)
}
const times = [
@ -49,7 +50,7 @@ const timeIndex = (string) => {
return times.find((item) => isNaN(diff) || diff < item.difference)?.text(date)
}
function createIndexer({ field, type = undefined } = {}) {
const createIndexer = ({ field, type } = {}) => {
switch (type) {
case String:
return (item) => characterIndex(item[field])
@ -65,7 +66,10 @@ function createIndexer({ field, type = undefined } = {}) {
}
export class GroupedList {
constructor({ items = [], total = 0, offset = 0, limit = -1 } = {}, options) {
constructor(
{ items = [], total = 0, offset = 0, limit = -1 } = {},
options = {}
) {
this.items = items
this.total = total
this.offset = offset

View File

@ -1,15 +1,15 @@
const toColor = (string) => {
var hash = 0
for (var i = 0; i < string.length; i++) {
hash = string.charCodeAt(i) + ((hash << 5) - hash)
let hash = 0
for (const char of string) {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
}
return (hash & 0x00ffffff).toString(16)
}
const luminance = (color) =>
[0.2126, 0.7152, 0.0722].reduce(
(luminance, factor, index) =>
luminance + Number(`0x${color.slice(index * 2, index * 2 + 2)}`) * factor,
(value, factor, index) =>
value + Number(`0x${color.slice(index * 2, index * 2 + 2)}`) * factor,
0
) / 255

View File

@ -1,13 +1,13 @@
import './mystyles.scss'
import App from './App.vue'
import VueClickAway from 'vue3-click-away'
import VueLazyLoad from 'vue3-lazyload'
import VueProgressBar from '@aacassandra/vue3-progressbar'
import { createApp } from 'vue'
import { filters } from './filter'
import i18n from './i18n'
import { icons } from './icons'
import mdiVue from 'mdi-vue/v3'
import VueClickAway from 'vue3-click-away'
import VueLazyLoad from 'vue3-lazyload'
import VueProgressBar from '@aacassandra/vue3-progressbar'
import { router } from './router'
import store from './store'

View File

@ -19,6 +19,7 @@
border-bottom-color: $grey-lighter;
color: $grey-lighter !important;
}
a:hover,
a.has-text-dark:hover,
a.has-text-dark:focus {
color: $grey-lighter !important;
@ -48,6 +49,7 @@
border-top-color: $grey-dark;
}
a.tag:hover,
a.tag.is-delete:hover,
a.dropdown-item:hover,
a.dropdown-item:focus,
a.navbar-item:hover,
@ -215,6 +217,14 @@ a.navbar-item {
min-height: calc(100vh - calc(2 * $navbar-height));
}
.is-disabled {
cursor: not-allowed;
opacity: 0.5;
> * {
pointer-events: none;
}
}
.fd-cover {
align-items: center;
display: flex;

View File

@ -146,25 +146,26 @@
keypath="page.about.built-with"
scope="global"
>
<template #bulma><a href="https://bulma.io">Bulma</a></template>
<template #mdi
><a href="https://pictogrammers.com/library/mdi/"
>Material Design Icons</a
></template
>
<template #vuejs
><a href="https://vuejs.org/">Vue.js</a></template
>
<template #axios
><a href="https://github.com/mzabriskie/axios"
>axios</a
></template
>
<template #others
><a
<template #bulma>
<a href="https://bulma.io">Bulma</a>
</template>
<template #mdi>
<a href="https://pictogrammers.com/library/mdi/">
Material Design Icons
</a>
</template>
<template #vuejs>
<a href="https://vuejs.org/">Vue.js</a>
</template>
<template #axios>
<a href="https://github.com/mzabriskie/axios">axios</a>
</template>
<template #others>
<a
href="https://github.com/owntone/owntone-server/network/dependencies"
v-text="$t('page.about.more')"
/></template>
/>
</template>
</i18n-t>
</div>
</div>

View File

@ -21,7 +21,7 @@
</template>
<template #heading-right>
<cover-artwork
:artwork_url="album.artwork_url"
:url="album.artwork_url"
:artist="album.artist"
:album="album.name"
class="is-clickable fd-has-shadow fd-cover fd-cover-medium-image"
@ -81,13 +81,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -25,7 +25,7 @@
</template>
<template #heading-right>
<cover-artwork
:artwork_url="artwork_url(album)"
:url="artwork_url(album)"
:artist="album.artists[0].name"
:album="album.name"
class="is-clickable fd-has-shadow fd-cover fd-cover-medium-image"
@ -87,13 +87,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -98,14 +98,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
albums_list: new GroupedList(),

View File

@ -107,13 +107,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -71,11 +71,11 @@ const dataObject = {
},
set(vm, response) {
vm.artist = response[0]
vm.artist = response.shift()
vm.albums = []
vm.total = 0
vm.offset = 0
vm.append_albums(response[1])
vm.append_albums(response.shift())
}
}
@ -93,13 +93,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -110,13 +110,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -98,14 +98,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
artists_list: new GroupedList(),

View File

@ -21,7 +21,7 @@
</template>
<template #heading-right>
<cover-artwork
:artwork_url="album.artwork_url"
:url="album.artwork_url"
:artist="album.artist"
:album="album.name"
class="is-clickable fd-has-shadow fd-cover fd-cover-medium-image"
@ -80,13 +80,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -54,14 +54,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
albums: new GroupedList()

View File

@ -69,14 +69,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
albums: new GroupedList(),

View File

@ -55,14 +55,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
artists: new GroupedList()

View File

@ -33,7 +33,7 @@ const dataObject = {
},
set(vm, response) {
vm.genres = new GroupedList(response.data, {
vm.genres = new GroupedList(response.data.genres, {
index: { field: 'name_sort', type: String }
})
}
@ -53,13 +53,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -79,13 +79,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -98,13 +98,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -49,14 +49,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
composers: new GroupedList()

View File

@ -2,8 +2,7 @@
<div>
<content-with-heading>
<template #heading-left>
<p class="title is-4" v-text="$t('page.files.title')" />
<p class="title is-7 has-text-grey" v-text="current_directory" />
<p class="title is-4" v-text="name" />
</template>
<template #heading-right>
<div class="buttons is-centered">
@ -20,7 +19,7 @@
</div>
</template>
<template #content>
<list-directories :items="dirs" />
<list-directories :items="directories" />
<list-playlists :items="playlists" />
<list-tracks
:expression="play_expression"
@ -28,7 +27,7 @@
:show_icon="true"
/>
<modal-dialog-directory
:item="current_directory"
:item="current"
:show="show_details_modal"
@close="show_details_modal = false"
/>
@ -53,17 +52,24 @@ const dataObject = {
}
return Promise.resolve()
},
set(vm, response) {
if (response) {
vm.dirs = response.data.directories
vm.playlists = new GroupedList(response.data.playlists)
vm.tracks = new GroupedList(response.data.tracks)
vm.directories = response.data.directories.map((directory) =>
vm.transform(directory.path)
)
} else if (vm.$store.state.config.directories) {
vm.directories = vm.$store.state.config.directories.map((path) =>
vm.transform(path)
)
} else {
vm.dirs = vm.$store.state.config.directories.map((dir) => ({ path: dir }))
vm.playlists = new GroupedList()
vm.tracks = new GroupedList()
webapi.config().then((config) => {
vm.directories = config.data.directories.map((path) =>
vm.transform(path)
)
})
}
vm.playlists = new GroupedList(response?.data.playlists)
vm.tracks = new GroupedList(response?.data.tracks)
}
}
@ -82,17 +88,17 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
dataObject.set(this, response)
next()
})
},
data() {
return {
dirs: [],
directories: [],
playlists: new GroupedList(),
show_details_modal: false,
tracks: new GroupedList()
@ -100,20 +106,26 @@ export default {
},
computed: {
current_directory() {
if (this.$route.query && this.$route.query.directory) {
return this.$route.query.directory
current() {
return this.$route.query?.directory || '/'
},
name() {
if (this.current !== '/') {
return this.current?.slice(this.current.lastIndexOf('/') + 1)
}
return '/'
return this.$t('page.files.title')
},
play_expression() {
return `path starts with "${this.current_directory}" order by path asc`
return `path starts with "${this.current}" order by path asc`
}
},
methods: {
play() {
webapi.player_play_expression(this.play_expression, false)
},
transform(path) {
return { path, name: path.slice(path.lastIndexOf('/') + 1) }
}
}
}

View File

@ -62,7 +62,7 @@ const dataObject = {
},
set(vm, response) {
vm.genre = response[0].data
vm.genre = response[0].data.genres.items.shift()
vm.albums = new GroupedList(response[1].data.albums, {
index: { field: 'name_sort', type: String }
})
@ -82,13 +82,7 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
albums: new GroupedList(),

View File

@ -73,7 +73,7 @@ const dataObject = {
},
set(vm, response) {
vm.genre = response[0].data
vm.genre = response[0].data.genres.items.shift()
vm.tracks_list = new GroupedList(response[1].data.tracks)
}
}
@ -93,13 +93,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -33,7 +33,7 @@ const dataObject = {
},
set(vm, response) {
vm.genres = new GroupedList(response.data, {
vm.genres = new GroupedList(response.data.genres, {
index: { field: 'name_sort', type: String }
})
}
@ -53,13 +53,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -15,8 +15,9 @@
<router-link
class="button is-light is-small is-rounded"
:to="{ name: 'music-recently-added' }"
>{{ $t('page.music.show-more') }}</router-link
>
{{ $t('page.music.show-more') }}
</router-link>
</p>
</nav>
</template>
@ -35,8 +36,9 @@
<router-link
class="button is-light is-small is-rounded"
:to="{ name: 'music-recently-played' }"
>{{ $t('page.music.show-more') }}</router-link
>
{{ $t('page.music.show-more') }}
</router-link>
</p>
</nav>
</template>
@ -86,14 +88,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
recently_added: [],

View File

@ -49,14 +49,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
recently_added: new GroupedList()

View File

@ -43,13 +43,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -15,8 +15,9 @@
<router-link
:to="{ name: 'music-spotify-new-releases' }"
class="button is-light is-small is-rounded"
>{{ $t('page.spotify.music.show-more') }}</router-link
>
{{ $t('page.spotify.music.show-more') }}
</router-link>
</p>
</nav>
</template>
@ -38,8 +39,9 @@
<router-link
:to="{ name: 'music-spotify-featured-playlists' }"
class="button is-light is-small is-rounded"
>{{ $t('page.spotify.music.show-more') }}</router-link
>
{{ $t('page.spotify.music.show-more') }}
</router-link>
</p>
</nav>
</template>
@ -48,45 +50,34 @@
</template>
<script>
import * as types from '@/store/mutation_types'
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ListAlbumsSpotify from '@/components/ListAlbumsSpotify.vue'
import ListPlaylistsSpotify from '@/components/ListPlaylistsSpotify.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import TabsMusic from '@/components/TabsMusic.vue'
import store from '@/store'
import webapi from '@/webapi'
const dataObject = {
load(to) {
if (
store.state.spotify_new_releases.length > 0 &&
store.state.spotify_featured_playlists.length > 0
) {
return Promise.resolve()
}
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(store.state.spotify.webapi_token)
return Promise.all([
spotifyApi.getNewReleases({
country: store.state.spotify.webapi_country,
limit: 50
}),
spotifyApi.getFeaturedPlaylists({
country: store.state.spotify.webapi_country,
limit: 50
})
])
return webapi.spotify().then(({ data }) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
return Promise.all([
spotifyApi.getNewReleases({
country: data.webapi_country,
limit: 3
}),
spotifyApi.getFeaturedPlaylists({
country: data.webapi_country,
limit: 3
})
])
})
},
set(vm, response) {
if (response) {
store.commit(types.SPOTIFY_NEW_RELEASES, response[0].albums.items)
store.commit(
types.SPOTIFY_FEATURED_PLAYLISTS,
response[1].playlists.items
)
}
vm.new_releases = response[0].albums.items
vm.featured_playlists = response[1].playlists.items
}
}
@ -104,20 +95,11 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
computed: {
featured_playlists() {
return this.$store.state.spotify_featured_playlists.slice(0, 3)
},
new_releases() {
return this.$store.state.spotify_new_releases.slice(0, 3)
data() {
return {
featured_playlists: [],
new_releases: []
}
}
}

View File

@ -16,31 +16,26 @@
</template>
<script>
import * as types from '@/store/mutation_types'
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ListPlaylistsSpotify from '@/components/ListPlaylistsSpotify.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import TabsMusic from '@/components/TabsMusic.vue'
import store from '@/store'
import webapi from '@/webapi'
const dataObject = {
load(to) {
if (store.state.spotify_featured_playlists.length > 0) {
return Promise.resolve()
}
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(store.state.spotify.webapi_token)
spotifyApi.getFeaturedPlaylists({
country: store.state.spotify.webapi_country,
limit: 50
return webapi.spotify().then(({ data }) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
return spotifyApi.getFeaturedPlaylists({
country: data.webapi_country,
limit: 50
})
})
},
set(vm, response) {
if (response) {
store.commit(types.SPOTIFY_FEATURED_PLAYLISTS, response.playlists.items)
}
vm.featured_playlists = response.playlists.items
}
}
@ -57,17 +52,10 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
computed: {
featured_playlists() {
return this.$store.state.spotify_featured_playlists
data() {
return {
featured_playlists: []
}
}
}

View File

@ -13,31 +13,26 @@
</template>
<script>
import * as types from '@/store/mutation_types'
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ListAlbumsSpotify from '@/components/ListAlbumsSpotify.vue'
import SpotifyWebApi from 'spotify-web-api-js'
import TabsMusic from '@/components/TabsMusic.vue'
import store from '@/store'
import webapi from '@/webapi'
const dataObject = {
load(to) {
if (store.state.spotify_new_releases.length > 0) {
return Promise.resolve()
}
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(store.state.spotify.webapi_token)
return spotifyApi.getNewReleases({
country: store.state.spotify.webapi_country,
limit: 50
return webapi.spotify().then(({ data }) => {
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
return spotifyApi.getNewReleases({
country: data.webapi_country,
limit: 50
})
})
},
set(vm, response) {
if (response) {
store.commit(types.SPOTIFY_NEW_RELEASES, response.albums.items)
}
vm.new_releases = response.albums.items
}
}
@ -54,17 +49,10 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
computed: {
new_releases() {
return this.$store.state.spotify_new_releases
data() {
return {
new_releases: []
}
}
}

View File

@ -3,7 +3,7 @@
<div v-if="track.id > 0" class="hero-body is-flex is-align-items-center">
<div class="container has-text-centered" style="max-width: 500px">
<cover-artwork
:artwork_url="track.artwork_url"
:url="track.artwork_url"
:artist="track.artist"
:album="track.album"
class="is-clickable fd-has-shadow fd-cover-big-image"
@ -86,53 +86,6 @@ export default {
},
computed: {
is_live() {
return this.track.length_ms === 0
},
lyrics_visible() {
return this.$store.state.lyrics.pane
},
player() {
return this.$store.state.player
},
track() {
return this.$store.getters.now_playing
},
track_progress: {
get() {
return Math.floor(this.player.item_progress_ms / INTERVAL)
},
set(value) {
this.player.item_progress_ms = value * INTERVAL
}
},
track_progress_max() {
return this.is_live ? 1 : Math.floor(this.track.length_ms / INTERVAL)
},
track_elapsed_time() {
return this.$filters.durationInHours(this.track_progress * INTERVAL)
},
track_total_time() {
return this.is_live
? this.$t('page.now-playing.live')
: this.$filters.durationInHours(this.track.length_ms)
},
settings_option_show_composer_now_playing() {
return this.$store.getters.settings_option_show_composer_now_playing
},
settings_option_show_composer_for_genre() {
return this.$store.getters.settings_option_show_composer_for_genre
},
composer() {
if (this.settings_option_show_composer_now_playing) {
if (
@ -151,16 +104,51 @@ export default {
}
return null
},
settings_option_show_filepath_now_playing() {
return this.$store.getters.settings_option_show_filepath_now_playing
},
filepath() {
if (this.settings_option_show_filepath_now_playing) {
return this.track.path
}
return null
},
is_live() {
return this.track.length_ms === 0
},
lyrics_visible() {
return this.$store.state.lyrics.pane
},
player() {
return this.$store.state.player
},
settings_option_show_composer_for_genre() {
return this.$store.getters.settings_option_show_composer_for_genre
},
settings_option_show_composer_now_playing() {
return this.$store.getters.settings_option_show_composer_now_playing
},
settings_option_show_filepath_now_playing() {
return this.$store.getters.settings_option_show_filepath_now_playing
},
track() {
return this.$store.getters.now_playing
},
track_elapsed_time() {
return this.$filters.durationInHours(this.track_progress * INTERVAL)
},
track_progress: {
get() {
return Math.floor(this.player.item_progress_ms / INTERVAL)
},
set(value) {
this.player.item_progress_ms = value * INTERVAL
}
},
track_progress_max() {
return this.is_live ? 1 : Math.floor(this.track.length_ms / INTERVAL)
},
track_total_time() {
return this.is_live
? this.$t('page.now-playing.live')
: this.$filters.durationInHours(this.track.length_ms)
}
},
@ -193,29 +181,25 @@ export default {
},
methods: {
tick() {
if (!this.is_dragged) {
this.track_progress += 1
}
},
start_dragging() {
this.is_dragged = true
},
end_dragging() {
this.is_dragged = false
},
open_dialog(item) {
this.selected_item = item
this.show_details_modal = true
},
seek() {
if (!this.is_live) {
webapi.player_seek_to_pos(this.track_progress * INTERVAL)
}
},
open_dialog(item) {
this.selected_item = item
this.show_details_modal = true
start_dragging() {
this.is_dragged = true
},
tick() {
if (!this.is_dragged) {
this.track_progress += 1
}
}
}
}

View File

@ -49,10 +49,10 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
dataObject.set(this, response)
next()
})
},

View File

@ -65,13 +65,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -72,11 +72,11 @@ const dataObject = {
},
set(vm, response) {
vm.playlist = response[0]
vm.playlist = response.shift()
vm.tracks = []
vm.total = 0
vm.offset = 0
vm.append_tracks(response[1])
vm.append_tracks(response.shift())
}
}
@ -94,13 +94,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -19,7 +19,7 @@
</template>
<template #heading-right>
<cover-artwork
:artwork_url="album.artwork_url"
:url="album.artwork_url"
:artist="album.artist"
:album="album.name"
class="is-clickable fd-has-shadow fd-cover fd-cover-medium-image"
@ -47,6 +47,7 @@
<modal-dialog
:show="show_remove_podcast_modal"
:title="$t('page.podcast.remove-podcast')"
:close_action="$t('page.podcast.cancel')"
:delete_action="$t('page.podcast.remove')"
@close="show_remove_podcast_modal = false"
@delete="remove_podcast"
@ -102,13 +103,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -94,14 +94,6 @@ export default {
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {
albums: [],
@ -125,7 +117,7 @@ export default {
this.new_episodes.items = {}
},
open_add_podcast_dialog(item) {
open_add_podcast_dialog() {
this.show_url_modal = true
},

View File

@ -135,10 +135,7 @@ export default {
computed: {
current_position() {
const nowPlaying = this.$store.getters.now_playing
return nowPlaying === undefined || nowPlaying.position === undefined
? -1
: this.$store.getters.now_playing.position
return this.$store.getters.now_playing?.position ?? -1
},
is_queue_save_allowed() {
return (
@ -175,27 +172,26 @@ export default {
webapi.queue_move(item.id, newPosition)
}
},
open_add_stream_dialog() {
this.show_url_modal = true
},
open_dialog(item) {
this.selected_item = item
this.show_details_modal = true
},
open_add_stream_dialog() {
this.show_url_modal = true
},
queue_clear() {
webapi.queue_clear()
},
remove(item) {
webapi.queue_remove(item.id)
},
update_show_next_items(e) {
this.$store.commit(types.SHOW_ONLY_NEXT_ITEMS, !this.show_only_next_items)
},
save_dialog() {
if (this.queue_items.length > 0) {
this.show_pls_save_modal = true
}
},
update_show_next_items() {
this.$store.commit(types.SHOW_ONLY_NEXT_ITEMS, !this.show_only_next_items)
}
}
}

View File

@ -40,13 +40,6 @@ export default {
next((vm) => dataObject.set(vm, response))
})
},
beforeRouteUpdate(to, from, next) {
const vm = this
dataObject.load(to).then((response) => {
dataObject.set(vm, response)
next()
})
},
data() {
return {

View File

@ -3,7 +3,7 @@
<div class="container">
<div class="columns is-centered">
<div class="column is-four-fifths">
<form @submit.prevent="new_search">
<form @submit.prevent="search">
<div class="field">
<p class="control has-icons-left">
<input
@ -23,52 +23,52 @@
scope="global"
>
<template #query><code>query:</code></template>
<template #help
><a
<template #help>
<a
href="https://owntone.github.io/owntone-server/smart-playlists/"
target="_blank"
v-text="$t('page.search.expression')"
/></template>
/>
</template>
</i18n-t>
</div>
</form>
<div class="tags mt-4">
<a
v-for="recent_search in recent_searches"
:key="recent_search"
class="tag"
@click="open_recent_search(recent_search)"
v-text="recent_search"
/>
<div class="field is-grouped is-grouped-multiline mt-4">
<div v-for="query in recent_searches" :key="query" class="control">
<div class="tags has-addons">
<a class="tag" @click="open_search(query)" v-text="query" />
<a class="tag is-delete" @click="remove_search(query)"></a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<tabs-search :query="search_query" />
<template v-for="type in search_types" :key="type">
<content-with-heading v-if="show(type)" class="pt-0">
<tabs-search @search-library="search" @search-spotify="search_spotify" />
<template v-for="[type, items] in results" :key="type">
<content-with-heading class="pt-0">
<template #heading-left>
<p class="title is-4" v-text="$t(`page.search.${type}s`)" />
</template>
<template #content>
<component :is="components[type]" :items="results[type]" />
<component :is="components[type]" :items="items" />
</template>
<template #footer>
<nav v-if="show_all_button(type)" class="level">
<template v-if="!expanded" #footer>
<nav v-if="show_all_button(items)" class="level">
<p class="level-item">
<a
class="button is-light is-small is-rounded"
@click="open_search(type)"
@click="expand(type)"
v-text="
$t(`page.search.show-${type}s`, results[type].total, {
count: $filters.number(results[type].total)
$t(`page.search.show-${type}s`, items.total, {
count: $filters.number(items.total)
})
"
/>
</p>
</nav>
<p v-if="!results[type].total" class="has-text-centered-mobile">
<p v-if="!items.total" class="has-text-centered-mobile">
<i v-text="$t('page.search.no-results')" />
</p>
</template>
@ -77,6 +77,7 @@
</template>
<script>
import * as types from '@/store/mutation_types'
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import { GroupedList } from '@/lib/GroupedList'
import ListAlbums from '@/components/ListAlbums.vue'
@ -87,6 +88,17 @@ import ListTracks from '@/components/ListTracks.vue'
import TabsSearch from '@/components/TabsSearch.vue'
import webapi from '@/webapi'
const PAGE_SIZE = 3,
SEARCH_TYPES = [
'track',
'artist',
'album',
'composer',
'playlist',
'audiobook',
'podcast'
]
export default {
name: 'PageSearchLibrary',
components: {
@ -110,140 +122,99 @@ export default {
podcast: ListAlbums.name,
track: ListTracks.name
},
results: {
album: new GroupedList(),
artist: new GroupedList(),
audiobook: new GroupedList(),
composer: new GroupedList(),
playlist: new GroupedList(),
podcast: new GroupedList(),
track: new GroupedList()
},
results: new Map(),
search_limit: {},
search_query: '',
search_types: [
'track',
'artist',
'album',
'composer',
'playlist',
'audiobook',
'podcast'
],
tracks: new GroupedList()
search_types: SEARCH_TYPES
}
},
computed: {
expanded() {
return this.search_types.length === 1
},
recent_searches() {
return this.$store.state.recent_searches
}
},
watch: {
$route(to, from) {
this.search(to)
search_query() {
this.$store.commit(types.SEARCH_QUERY, this.search_query)
}
},
mounted() {
this.search(this.$route)
this.$store.commit(types.SEARCH_SOURCE, this.$route.name)
this.search_query = this.$store.state.search_query
this.search_limit = PAGE_SIZE
this.search()
},
methods: {
new_search() {
if (!this.search_query) {
return
}
this.$router.push({
name: 'search-library',
query: {
limit: 3,
offset: 0,
query: this.search_query,
type: this.search_types.join()
}
})
this.$refs.search_field.blur()
expand(type) {
this.search_query = this.$store.state.search_query
this.search_types = [type]
this.search_limit = -1
this.search()
},
open_recent_search(query) {
open_search(query) {
this.search_query = query
this.new_search()
this.search_types = SEARCH_TYPES
this.search_limit = PAGE_SIZE
this.search()
},
open_search(type) {
this.$router.push({
name: 'search-library',
query: { query: this.$route.query.query, type }
remove_search(query) {
this.$store.dispatch('remove_recent_search', query)
},
reset() {
this.results.clear()
this.search_types.forEach((type) => {
this.results.set(type, new GroupedList())
})
},
search(route) {
this.search_query = route.query.query?.trim()
search(event) {
if (event) {
this.search_types = SEARCH_TYPES
this.search_limit = PAGE_SIZE
}
this.search_query = this.search_query.trim()
if (!this.search_query || !this.search_query.replace(/^query:/u, '')) {
this.$refs.search_field.focus()
return
}
route.query.query = this.search_query
this.searchMusic(route.query)
this.searchType(route.query, 'audiobook')
this.searchType(route.query, 'podcast')
this.reset()
this.search_types.forEach((type) => {
this.search_items(type)
})
this.$store.dispatch('add_recent_search', this.search_query)
},
searchMusic(query) {
if (
!query.type.includes('track') &&
!query.type.includes('artist') &&
!query.type.includes('album') &&
!query.type.includes('playlist') &&
!query.type.includes('composer')
) {
return
}
search_items(type) {
const music = type !== 'audiobook' && type !== 'podcast'
const kind = music ? 'music' : type
const parameters = {
type: query.type
limit: this.search_limit,
type: music ? type : 'album'
}
if (query.query.startsWith('query:')) {
parameters.expression = `(${query.query.replace(/^query:/u, '').trim()}) and media_kind is music`
if (this.search_query.startsWith('query:')) {
parameters.expression = `(${this.search_query.replace(/^query:/u, '').trim()}) and media_kind is ${kind}`
} else if (music) {
parameters.query = this.search_query
parameters.media_kind = kind
} else {
parameters.query = query.query
parameters.media_kind = 'music'
}
if (query.limit) {
parameters.limit = query.limit
parameters.offset = query.offset
parameters.expression = `(album includes "${this.search_query}" or artist includes "${this.search_query}") and media_kind is ${kind}`
}
webapi.search(parameters).then(({ data }) => {
this.results.track = new GroupedList(data.tracks)
this.results.artist = new GroupedList(data.artists)
this.results.album = new GroupedList(data.albums)
this.results.composer = new GroupedList(data.composers)
this.results.playlist = new GroupedList(data.playlists)
this.results.set(type, new GroupedList(data[`${parameters.type}s`]))
})
},
searchType(query, type) {
if (!query.type.includes(type)) {
return
}
const parameters = {
type: 'album'
}
if (query.query.startsWith('query:')) {
parameters.expression = query.query.replace(/^query:/u, '').trim()
} else {
parameters.expression = `album includes "${query.query}" or artist includes "${query.query}"`
}
parameters.expression = `(${parameters.expression}) and media_kind is ${type}`
if (query.limit) {
parameters.limit = query.limit
parameters.offset = query.offset
}
webapi.search(parameters).then(({ data }) => {
this.results[type] = new GroupedList(data.albums)
})
search_spotify() {
this.$router.push({ name: 'search-spotify' })
},
show(type) {
return this.$route.query.type?.includes(type) ?? false
return this.search_types.includes(type)
},
show_all_button(type) {
const items = this.results[type]
show_all_button(items) {
return items.total > items.items.length
}
}

View File

@ -3,7 +3,7 @@
<div class="container">
<div class="columns is-centered">
<div class="column is-four-fifths">
<form @submit.prevent="new_search">
<form @submit.prevent="search">
<div class="field">
<p class="control has-icons-left">
<input
@ -18,28 +18,27 @@
</p>
</div>
</form>
<div class="tags mt-4">
<a
v-for="recent_search in recent_searches"
:key="recent_search"
class="tag"
@click="open_recent_search(recent_search)"
v-text="recent_search"
/>
<div class="field is-grouped is-grouped-multiline mt-4">
<div v-for="query in recent_searches" :key="query" class="control">
<div class="tags has-addons">
<a class="tag" @click="open_search(query)" v-text="query" />
<a class="tag is-delete" @click="remove_search(query)"></a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<tabs-search :query="search_query" />
<template v-for="type in search_types" :key="type">
<content-with-heading v-if="show(type)" class="pt-0">
<tabs-search @search-library="search_library" @search-spotify="search" />
<template v-for="[type, items] in results" :key="type">
<content-with-heading class="pt-0">
<template #heading-left>
<p class="title is-4" v-text="$t(`page.spotify.search.${type}s`)" />
</template>
<template #content>
<component :is="components[type]" :items="results[type].items" />
<VueEternalLoading v-if="query.type === type" :load="search_next">
<component :is="components[type]" :items="items.items" />
<VueEternalLoading v-if="expanded" :load="search_next">
<template #loading>
<div class="columns is-centered">
<div class="column has-text-centered">
@ -50,21 +49,21 @@
<template #no-more>&nbsp;</template>
</VueEternalLoading>
</template>
<template #footer>
<nav v-if="show_all_button(type)" class="level">
<template v-if="!expanded" #footer>
<nav v-if="show_all_button(items)" class="level">
<p class="level-item">
<a
class="button is-light is-small is-rounded"
@click="open_search(type)"
@click="expand(type)"
v-text="
$t(`page.spotify.search.show-${type}s`, results[type].total, {
count: $filters.number(results[type].total)
$t(`page.spotify.search.show-${type}s`, items.total, {
count: $filters.number(items.total)
})
"
/>
</p>
</nav>
<p v-if="!results[type].total" class="has-text-centered-mobile">
<p v-if="!items.total" class="has-text-centered-mobile">
<i v-text="$t(`page.spotify.search.no-results`)" />
</p>
</template>
@ -73,6 +72,7 @@
</template>
<script>
import * as types from '@/store/mutation_types'
import ContentWithHeading from '@/templates/ContentWithHeading.vue'
import ListAlbumsSpotify from '@/components/ListAlbumsSpotify.vue'
import ListArtistsSpotify from '@/components/ListArtistsSpotify.vue'
@ -83,7 +83,9 @@ import TabsSearch from '@/components/TabsSearch.vue'
import { VueEternalLoading } from '@ts-pro/vue-eternal-loading'
import webapi from '@/webapi'
const PAGE_SIZE = 50
const PAGE_SIZE = 3,
PAGE_SIZE_EXPANDED = 50,
SEARCH_TYPES = ['track', 'artist', 'album', 'playlist']
export default {
name: 'PageSearchSpotify',
@ -105,119 +107,116 @@ export default {
playlist: ListPlaylistsSpotify.name,
track: ListTracksSpotify.name
},
query: {},
results: {
album: { items: [], total: 0 },
artist: { items: [], total: 0 },
playlist: { items: [], total: 0 },
track: { items: [], total: 0 }
},
search_param: {},
results: new Map(),
search_parameters: {},
search_query: '',
search_types: ['track', 'artist', 'album', 'playlist']
search_types: SEARCH_TYPES
}
},
computed: {
expanded() {
return this.search_types.length === 1
},
recent_searches() {
return this.$store.state.recent_searches.filter(
(search) => !search.startsWith('query:')
(query) => !query.startsWith('query:')
)
}
},
watch: {
$route(to, from) {
this.query = to.query
this.search()
search_query() {
this.$store.commit(types.SEARCH_QUERY, this.search_query)
}
},
mounted() {
this.query = this.$route.query
this.$store.commit(types.SEARCH_SOURCE, this.$route.name)
this.search_query = this.$store.state.search_query
this.search_parameters.limit = PAGE_SIZE
this.search()
},
methods: {
new_search() {
if (!this.search_query) {
return
}
this.$router.push({
name: 'search-spotify',
query: {
limit: 3,
offset: 0,
query: this.search_query,
type: 'track,artist,album,playlist,audiobook,podcast'
}
})
this.$refs.search_field.blur()
expand(type) {
this.search_query = this.$store.state.search_query
this.search_types = [type]
this.search_parameters.limit = PAGE_SIZE_EXPANDED
this.search_parameters.offset = 0
this.search()
},
open_recent_search(query) {
open_search(query) {
this.search_query = query
this.new_search()
this.search_types = SEARCH_TYPES
this.search_parameters.limit = PAGE_SIZE
this.search_parameters.offset = 0
this.search()
},
open_search(type) {
this.$router.push({
name: 'search-spotify',
query: { query: this.$route.query.query, type }
})
remove_search(query) {
this.$store.dispatch('remove_recent_search', query)
},
reset() {
Object.entries(this.results).forEach(
(key) => (this.results[key] = { items: [], total: 0 })
)
this.results.clear()
this.search_types.forEach((type) => {
this.results.set(type, { items: [], total: 0 })
})
},
search() {
this.reset()
this.search_query = this.query.query?.trim()
if (!this.search_query || this.search_query.startsWith('query:')) {
this.search_query = ''
search(event) {
if (event) {
this.search_types = SEARCH_TYPES
this.search_parameters.limit = PAGE_SIZE
}
this.search_query = this.search_query.trim()
if (!this.search_query) {
this.$refs.search_field.focus()
return
}
this.query.query = this.search_query
this.search_param.limit = this.query.limit ?? PAGE_SIZE
this.search_param.offset = this.query.offset ?? 0
this.$store.dispatch('add_recent_search', this.query.query)
this.search_all()
this.reset()
this.search_items().then((data) => {
this.search_types.forEach((type) => {
this.results.set(type, data[`${type}s`])
})
})
this.$store.dispatch('add_recent_search', this.search_query)
},
search_all() {
const types = this.query.type
.split(',')
.filter((type) => this.search_types.includes(type))
this.spotify_search(types).then((data) => {
this.results.track = data.tracks ?? { items: [], total: 0 }
this.results.artist = data.artists ?? { items: [], total: 0 }
this.results.album = data.albums ?? { items: [], total: 0 }
this.results.playlist = data.playlists ?? { items: [], total: 0 }
search_items() {
return webapi.spotify().then(({ data }) => {
this.search_parameters.market = data.webapi_country
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
return spotifyApi.search(
this.search_query,
this.search_types,
this.search_parameters
)
})
},
search_library() {
this.$router.push({
name: 'search-library'
})
},
search_next({ loaded }) {
const items = this.results[this.query.type]
this.spotify_search([this.query.type]).then((data) => {
const [type] = this.search_types,
items = this.results.get(type)
this.search_parameters.limit = PAGE_SIZE_EXPANDED
this.search_items().then((data) => {
const [next] = Object.values(data)
items.items.push(...next.items)
items.total = next.total
this.search_param.offset += next.limit
loaded(next.items.length, PAGE_SIZE)
if (!this.search_parameters.offset) {
this.search_parameters.offset = 0
}
this.search_parameters.offset += next.limit
loaded(next.items.length, PAGE_SIZE_EXPANDED)
})
},
show(type) {
return this.$route.query.type?.includes(type) ?? false
return this.search_types.includes(type)
},
show_all_button(type) {
const items = this.results[type]
show_all_button(items) {
return items.total > items.items.length
},
spotify_search(types) {
return webapi.spotify().then(({ data }) => {
this.search_param.market = data.webapi_country
const spotifyApi = new SpotifyWebApi()
spotifyApi.setAccessToken(data.webapi_token)
return spotifyApi.search(this.query.query, types, this.search_param)
})
}
}
}

View File

@ -6,10 +6,9 @@
<p class="title is-4" v-text="$t('page.settings.devices.pairing')" />
</template>
<template #content>
<!-- Paring request active -->
<div v-if="pairing.active" class="notification">
<div v-if="pairing.active">
<form @submit.prevent="kickoff_pairing">
<label class="label has-text-weight-normal">
<label class="label has-text-weight-normal content">
<span v-text="$t('page.settings.devices.pairing-request')" />
<b v-text="pairing.remote" />
</label>
@ -18,7 +17,7 @@
<input
v-model="pairing_req.pin"
class="input"
type="text"
inputmode="numeric"
pattern="[\d]{4}"
:placeholder="$t('page.settings.devices.pairing-code')"
/>
@ -33,8 +32,7 @@
</div>
</form>
</div>
<!-- No pairing requests -->
<div v-if="!pairing.active" class="content">
<div v-else>
<p v-text="$t('page.settings.devices.no-active-pairing')" />
</div>
</template>
@ -74,7 +72,7 @@
<input
v-model="verification_req.pin"
class="input"
type="text"
inputmode="numeric"
pattern="[\d]{4}"
:placeholder="$t('page.settings.devices.verification-code')"
/>

View File

@ -48,6 +48,10 @@ const TOP_WITHOUT_TABS = 110
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/:all(.*)*',
redirect: '/'
},
{
component: PageAbout,
name: 'about',

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