Compare commits
88 Commits
36ca3d921e
...
be88105a7f
Author | SHA1 | Date |
---|---|---|
gd | be88105a7f | |
github-actions[bot] | 1e73ba4754 | |
Alain Nussbaumer | b20bdda8e9 | |
Alain Nussbaumer | 7d7d38b946 | |
Alain Nussbaumer | 4268f41a51 | |
Alain Nussbaumer | bab6146345 | |
Alain Nussbaumer | 978e344ce2 | |
Alain Nussbaumer | f156bb357a | |
Alain Nussbaumer | 3f3ab829c0 | |
Alain Nussbaumer | 195135b1b6 | |
Alain Nussbaumer | 4c70105b5e | |
github-actions[bot] | 73abc84979 | |
Alain Nussbaumer | d4826695e3 | |
github-actions[bot] | 715e9d32eb | |
Alain Nussbaumer | 25e005ff32 | |
ejurgensen | 263a197da4 | |
Alain Nussbaumer | 52a915c8a0 | |
Alain Nussbaumer | 67e67c8db9 | |
github-actions[bot] | 0873c6cb65 | |
Alain Nussbaumer | 1ef62ac3a6 | |
github-actions[bot] | 06f658e1c4 | |
Alain Nussbaumer | a2000c0bc7 | |
Alain Nussbaumer | c3d5c6eab9 | |
github-actions[bot] | 0d11f732e1 | |
Alain Nussbaumer | d6391621a0 | |
Alain Nussbaumer | b8373a4ee0 | |
Alain Nussbaumer | 2fda829ac4 | |
Alain Nussbaumer | 5115e04664 | |
Alain Nussbaumer | 369afe11e3 | |
Alain Nussbaumer | 9690bc2447 | |
github-actions[bot] | acf8805dac | |
Alain Nussbaumer | 58fbcd7e7a | |
Alain Nussbaumer | ae973f312a | |
Alain Nussbaumer | 185e09c118 | |
github-actions[bot] | 595c91d5d6 | |
Alain Nussbaumer | 465232f8b9 | |
Alain Nussbaumer | 13ff8fdb8e | |
Alain Nussbaumer | 5ce78d041d | |
ejurgensen | 6a93172cb9 | |
ejurgensen | f00aae6c6c | |
github-actions[bot] | 16b9de01c7 | |
Alain Nussbaumer | 1ccc97d824 | |
Alain Nussbaumer | a2dd2251c9 | |
ejurgensen | 72454de4ef | |
ejurgensen | 677aceccb6 | |
ejurgensen | 60872e0a5a | |
Alain Nussbaumer | c1842e383a | |
Alain Nussbaumer | 867ab0e80a | |
Alain Nussbaumer | 59a734b04c | |
github-actions[bot] | 183f6f8ed9 | |
Alain Nussbaumer | ff9537514a | |
github-actions[bot] | 60f14adb47 | |
Alain Nussbaumer | 5e39828966 | |
Alain Nussbaumer | 0362896bfb | |
Alain Nussbaumer | e5e7702fc5 | |
ejurgensen | c96c3966f4 | |
ejurgensen | aaf349bbcc | |
ejurgensen | cd5937bbb7 | |
ejurgensen | 1c17231b9e | |
ejurgensen | a8342dc513 | |
ejurgensen | 945bde7c66 | |
github-actions[bot] | 1c26681a65 | |
Alain Nussbaumer | 31661edc03 | |
Alain Nussbaumer | 4946c0e43c | |
Alain Nussbaumer | 81d9b1723f | |
github-actions[bot] | 089df85c1d | |
Alain Nussbaumer | 839e475c3e | |
Alain Nussbaumer | 72b30aabf9 | |
github-actions[bot] | 40c423ee3c | |
Alain Nussbaumer | d49074eeae | |
Alain Nussbaumer | be931f4173 | |
Alain Nussbaumer | 5640c33a67 | |
github-actions[bot] | 285270f598 | |
Alain Nussbaumer | 4b52df676a | |
Alain Nussbaumer | 7b41980ace | |
Alain Nussbaumer | cbedb4d38c | |
Alain Nussbaumer | 2451ac608f | |
Alain Nussbaumer | 7be1989cd4 | |
ejurgensen | 3e7e03b4c1 | |
github-actions[bot] | 39f5df8ade | |
Alain Nussbaumer | 0a0568c2f5 | |
Alain Nussbaumer | 6577004536 | |
Alain Nussbaumer | ad2d0e0bba | |
github-actions[bot] | eecd276aa3 | |
Alain Nussbaumer | 06a23ea29a | |
gd | 0e39b88fdf | |
gd | 44d91ab858 | |
gd | b7dd32b64e |
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
129
docs/building.md
129
docs/building.md
|
@ -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`
|
||||
|
|
|
@ -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 |
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) |
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
319
docs/json-api.md
319
docs/json-api.md
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
@ -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
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
|
|
74
src/db.c
74
src/db.c
|
@ -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
|
||||
|
|
5
src/db.h
5
src/db.h
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
#include <json.h>
|
||||
|
||||
#include "conffile.h"
|
||||
#include "misc.h"
|
||||
#include "mdns.h"
|
||||
#include "transcode.h"
|
||||
#include "logger.h"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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']
|
||||
}
|
||||
}
|
|
@ -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
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/>'
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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 ?",
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
}
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "取消",
|
||||
"add": {
|
||||
"rss": {
|
||||
"add": "添加",
|
||||
|
@ -385,6 +384,7 @@
|
|||
"count": "{count} 个播放列表|{count} 个播放列表"
|
||||
},
|
||||
"podcast": {
|
||||
"cancel": "取消",
|
||||
"play": "播放",
|
||||
"remove": "移除",
|
||||
"remove-info-1": "从资料库中永久移除该播客?",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> </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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')"
|
||||
/>
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue