diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3a4d5f0..4d1a2a51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,7 +127,7 @@ jobs: name: 'Publish: DockerHub' runs-on: ubuntu-latest needs: [jest, tox] - if: github.event_name == 'release' + if: github.event_name == 'release' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master' steps: - name: Set up QEMU uses: docker/setup-qemu-action@v1 @@ -135,8 +135,14 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - - name: Generating tags and labels - id: meta + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch + + - name: Generating release tags and labels + if: github.event_name == 'release' + id: release_meta uses: docker/metadata-action@v4 with: images: jaedb/iris @@ -145,6 +151,14 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} + - name: Generating edge branch labels + if: github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/master' + id: edge_meta + uses: docker/metadata-action@v4 + with: + images: jaedb/iris + tags: ${{ steps.extract_branch.outputs.branch }} + - name: Login to DockerHub uses: docker/login-action@v1 with: @@ -155,5 +169,5 @@ jobs: uses: docker/build-push-action@v2 with: push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.release_meta.outputs.tags || steps.edge_meta.outputs.tags }} + labels: ${{ steps.release_meta.outputs.labels || steps.edge_meta.outputs.tags }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 2a1da99f..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,70 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '29 5 * * 4' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'javascript', 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # 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 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..ca3f1e5c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v14 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile old mode 100755 new mode 100644 index 983170fb..c883991f --- a/Dockerfile +++ b/Dockerfile @@ -1,80 +1,171 @@ -FROM debian:buster +# --- Build Node --- +FROM rust:slim-bullseye AS Builder +LABEL org.opencontainers.image.authors="https://github.com/seppi91" +ARG TARGETPLATFORM +ARG TARGETARCH +ARG TARGETVARIANT + +# Print Info about current build Target +RUN printf "I'm building for TARGETPLATFORM=${TARGETPLATFORM}" \ + && printf ", TARGETARCH=${TARGETARCH}" \ + && printf ", TARGETVARIANT=${TARGETVARIANT} \n" \ + && printf "With uname -s : " && uname -s \ + && printf "and uname -m : " && uname -mm # Switch to the root user while we do our changes USER root +# Install all libraries and needs +RUN apt update \ + && apt install -yq --no-install-recommends \ + git \ + patch \ + libgstreamer-plugins-base1.0-dev \ + libgstreamer1.0-dev \ + libcsound64-dev \ + libclang-11-dev \ + libpango1.0-dev \ + libdav1d-dev \ + # libgtk-4-dev \ Only in bookworm + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/gst-plugins-rs + +# Clone source of gst-plugins-rs to workdir +ARG GST_PLUGINS_RS_TAG=main +RUN git clone -c advice.detachedHead=false \ + --single-branch --depth 1 \ + --branch ${GST_PLUGINS_RS_TAG} \ + https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs.git ./ +# EXPERIMENTAL: For gstreamer-spotify set upgraded version number of dependency librespot to 0.4.2 +RUN sed -i 's/librespot = { version = "0.4", default-features = false }/librespot = { version = "0.4.2", default-features = false }/g' audio/spotify/Cargo.toml + +# Build GStreamer plugins written in Rust (optional with --no-default-features) +ENV DEST_DIR /target/gst-plugins-rs +ENV CARGO_PROFILE_RELEASE_DEBUG false +RUN export CSOUND_LIB_DIR="/usr/lib/$(uname -m)-linux-gnu" \ + && export PLUGINS_DIR=$(pkg-config --variable=pluginsdir gstreamer-1.0) \ + && export SO_SUFFIX=so \ + && cargo build --release --no-default-features \ + # List of packages to build + --package gst-plugin-spotify \ + # Use install command to create directory (-d), copy and print filenames (-v), and set attributes/permissions (-m) + && install -v -d ${DEST_DIR}/${PLUGINS_DIR} \ + && install -v -m 755 target/release/*.${SO_SUFFIX} ${DEST_DIR}/${PLUGINS_DIR} + + +# --- Release Node --- +FROM debian:bullseye-slim as Release + +# Switch to the root user while we do our changes +USER root +WORKDIR / + # Install GStreamer and other required Debian packages RUN apt-get update \ - && apt-get install -y --no-install-recommends \ + && apt-get install -y --no-install-recommends \ + sudo \ + build-essential \ + curl \ + git \ wget \ gnupg2 \ - git \ - python3-setuptools \ - python3-pip \ dumb-init \ graphviz-dev \ - gstreamer1.0-plugins-bad \ - gstreamer1.0-plugins-good \ - gstreamer1.0-plugins-ugly \ - gstreamer1.0-pulseaudio \ + pulseaudio \ libasound2-dev \ - python3-dev \ - python3-gst-1.0 \ - build-essential \ libdbus-glib-1-dev \ libgirepository1.0-dev \ - dleyna-server \ - sudo \ - && rm -rf /var/lib/apt/lists/* + # Install Python + python3-dev \ + python3-gst-1.0 \ + python3-setuptools \ + python3-pip \ + # GStreamer (Plugins) + gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-bad \ + gstreamer1.0-plugins-ugly \ + gstreamer1.0-libav \ + gstreamer1.0-pulseaudio \ + && rm -rf /var/lib/apt/lists/* -# Install libspotify-dev from apt.mopidy.com +# Copy builded target data from Builder DEST_DIR to root +# Note: target directory tree links directly to $GST_PLUGIN_PATH +COPY --from=Builder /target/gst-plugins-rs/ / + +# Install Node, to build Iris JS application +RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - && \ + apt-get install -y nodejs + +# Install mopidy and (optional) DLNA-server dleyna from apt.mopidy.com +# see https://docs.mopidy.com/en/latest/installation/debian/ RUN mkdir -p /usr/local/share/keyrings \ - && wget -q -O /usr/local/share/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg \ - && wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/buster.list \ - && apt-get update \ - && apt-get install -y libspotify-dev mopidy-spotify \ - && rm -rf /var/lib/apt/lists/* + && wget -q -O /usr/local/share/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg \ + && wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/buster.list \ + && apt-get update \ + && apt-get install -y \ + mopidy \ + && rm -rf /var/lib/apt/lists/* + +# Upgrade Python package manager pip +# https://pypi.org/project/pip/ +RUN python3 -m pip install --upgrade pip # Clone Iris from the repository and install in development mode. # This allows a binding at "/iris" to map to your local folder for development, rather than # installing using pip. -# Note using ADD helps prevent caching issues. When HEAD changes, our cache is invalidated, whee! +# Note: ADD helps prevent RUN caching issues. When HEAD changes in repo, our cache will be invalidated! ADD https://api.github.com/repos/jaedb/Iris/git/refs/heads/master version.json -RUN git clone --depth 1 -b master https://github.com/jaedb/Iris.git /iris \ +ENV IRIS_VERSION=develop +RUN git clone --depth 1 --single-branch -b ${IRIS_VERSION} https://github.com/jaedb/Iris.git /iris \ && cd /iris \ + && npm install \ + && npm run prod \ && python3 setup.py develop \ && mkdir -p /var/lib/mopidy/.config \ && ln -s /config /var/lib/mopidy/.config/mopidy \ # Allow mopidy user to run system commands (restart, local scan, etc) - && echo "mopidy ALL=NOPASSWD: /iris/mopidy_iris/system.sh" >> /etc/sudoers + && echo "mopidy ALL=NOPASSWD: /iris/mopidy_iris/system.sh" >> /etc/sudoers \ + # Enable container mode (disable restart option, etc.) + && echo "1" >> /IS_CONTAINER \ + # Copy Version file + && cp /iris/VERSION / -# Install additional Python dependencies -RUN python3 -m pip install --no-cache \ - tox \ - mopidy-mpd \ - mopidy-local +# Install mopidy-spotify-gstspotify (Hack, not released yet!) +# (https://github.com/kingosticks/mopidy-spotify/tree/gstspotifysrc-hack) +RUN git clone --depth 1 -b gstspotifysrc-hack https://github.com/kingosticks/mopidy-spotify.git mopidy-spotify \ + && cd mopidy-spotify \ + && python3 setup.py install \ + && cd .. \ + && rm -rf mopidy-spotify + +# Install additional mopidy extensions and Python dependencies via pip +COPY docker/requirements.txt . +RUN python3 -m pip install -r requirements.txt + +# Cleanup +RUN apt-get clean all \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /root/.cache \ + && rm -rf /iris/node_modules # Start helper script. COPY docker/entrypoint.sh /entrypoint.sh -# Default configuration. +# Copy Default configuration for mopidy COPY docker/mopidy/mopidy.example.conf /config/mopidy.conf -# Copy the pulse-client configuratrion. +# Copy the pulse-client configuratrion COPY docker/mopidy/pulse-client.conf /etc/pulse/client.conf -# Add version info to image -COPY VERSION / - # Allows any user to run mopidy, but runs by default as a randomly generated UID/GID. # RUN useradd -ms /bin/bash mopidy ENV HOME=/var/lib/mopidy RUN set -ex \ - && usermod -G audio,sudo mopidy \ + && usermod -G audio,sudo,pulse-access mopidy \ && mkdir /var/lib/mopidy/local \ && chown mopidy:audio -R $HOME /entrypoint.sh /iris \ - && chmod go+rwx -R $HOME /entrypoint.sh /iris \ - && echo "1" >> /IS_CONTAINER + && chmod go+rwx -R $HOME /entrypoint.sh /iris # Runs as mopidy user by default. USER mopidy:audio diff --git a/MANIFEST.in b/MANIFEST.in index 7a512fc1..0957ae8a 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,4 +15,5 @@ recursive-exclude src * recursive-exclude docker * exclude .jshintrc exclude .babelrc -exclude jest.config.js \ No newline at end of file +exclude jest.config.js +exclude .nvmrc \ No newline at end of file diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 82aaadb3..3d703863 100755 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -23,6 +23,7 @@ services: - 6600:6600 - 6680:6680 volumes: + # - ./mopidy/iris:/iris/mopidy/iris # To use a locally-built UI - ./docker/mopidy/iris:/var/lib/mopidy/iris # Iris-specific storage - ./docker/mopidy/m3u:/var/lib/mopidy/m3u # To persist local playlists - ./docker/mopidy/mopidy.conf:/config/mopidy.conf diff --git a/docker/requirements.txt b/docker/requirements.txt new file mode 100644 index 00000000..3be2d1a1 --- /dev/null +++ b/docker/requirements.txt @@ -0,0 +1,9 @@ +Mopidy-Local +Mopidy-Mpd +Mopidy-MusicBox-Webclient +Mopidy-Soundcloud +Mopidy-Youtube +Mopidy-YTMusic +ytmusicapi +youtube_dl +tox \ No newline at end of file diff --git a/src/js/components/GridItem.js b/src/js/components/GridItem.js index 0f427763..737f4543 100755 --- a/src/js/components/GridItem.js +++ b/src/js/components/GridItem.js @@ -101,7 +101,7 @@ const GridItem = ({ dispatch(spotifyActions.getArtistImages(item)); } break; - // case 'playlist': + case 'playlist': case 'album': dispatch(mopidyActions.getImages([item.uri])); break; @@ -122,7 +122,7 @@ const GridItem = ({ to = `/${item.type}/${encodeUri(item.uri)}`; if (item.name && item.type !== 'artist') { // Strip out "%"; this causes conflicts with our uri decoder - to += `/${encodeURIComponent(item.name.replace('%', ''))}`; + to += `/${encodeURIComponent(item.name.replace('%', '').replace('/', ''))}`; } } diff --git a/src/js/components/Sidebar.js b/src/js/components/Sidebar.js old mode 100755 new mode 100644 index 2806b609..b385e076 --- a/src/js/components/Sidebar.js +++ b/src/js/components/Sidebar.js @@ -72,7 +72,9 @@ const StatusIcon = () => { const Sidebar = () => { const dispatch = useDispatch(); - const spotify_available = useSelector((state) => state.spotify.access_token); + const spotify_enabled = useSelector((state) => state.spotify.enabled); + const spotify_has_token = useSelector((state) => state.spotify.access_token); + const spotify_available = spotify_enabled && spotify_has_token; const close = () => dispatch(toggleSidebar(false)); @@ -109,16 +111,16 @@ const Sidebar = () => { + + + + {spotify_available && ( - - - + + + )} - - - -
diff --git a/src/js/services/core/actions.js b/src/js/services/core/actions.js index e97e271d..914359a6 100755 --- a/src/js/services/core/actions.js +++ b/src/js/services/core/actions.js @@ -371,9 +371,7 @@ export function reorderPlaylistTracks(uri, indexes, insert_before, snapshot_id = insert_before, snapshot_id, }; - - case 'm3u': - case 'gmusic': + default: return { type: 'MOPIDY_REORDER_PLAYLIST_TRACKS', key: uri, @@ -381,40 +379,22 @@ export function reorderPlaylistTracks(uri, indexes, insert_before, snapshot_id = range_length: range.length, insert_before, }; - - default: - return { - type: 'UNSUPPORTED_ACTION', - name: 'reorderPlaylistTracks', - }; } } -export function savePlaylist(uri, name, description = '', is_public = false, is_collaborative = false, image = null) { +export function savePlaylist(uri, data) { switch (uriSource(uri)) { case 'spotify': return { type: 'SPOTIFY_SAVE_PLAYLIST', key: uri, - name, - description: (description == '' ? null : description), - image, - is_public, - is_collaborative, + data, }; - - case 'm3u': - case 'gmusic': + default: return { type: 'MOPIDY_SAVE_PLAYLIST', key: uri, - name, - }; - - default: - return { - type: 'UNSUPPORTED_ACTION', - name: 'savePlaylist', + data, }; } } @@ -432,7 +412,6 @@ export function deletePlaylist(uri) { switch (uriSource(uri)) { case 'spotify': return spotifyActions.following(uri, 'DELETE'); - default: return mopidyActions.deletePlaylist(uri); } @@ -446,20 +425,12 @@ export function removeTracksFromPlaylist(uri, tracks_indexes) { key: uri, tracks_indexes, }; - - case 'm3u': - case 'gmusic': + default: return { type: 'MOPIDY_REMOVE_PLAYLIST_TRACKS', key: uri, tracks_indexes, }; - - default: - return { - type: 'UNSUPPORTED_ACTION', - name: 'removeTracksFromPlaylist', - }; } } @@ -471,20 +442,12 @@ export function addTracksToPlaylist(uri, tracks_uris) { key: uri, tracks_uris, }; - - case 'm3u': - case 'gmusic': + default: return { type: 'MOPIDY_ADD_PLAYLIST_TRACKS', key: uri, tracks_uris, }; - - default: - return { - type: 'UNSUPPORTED_ACTION', - name: 'addTracksToPlaylist', - }; } } diff --git a/src/js/services/core/middleware.js b/src/js/services/core/middleware.js index be1a4b6b..2fe79c2f 100755 --- a/src/js/services/core/middleware.js +++ b/src/js/services/core/middleware.js @@ -275,10 +275,8 @@ const CoreMiddleware = (function () { case 'spotify': store.dispatch(spotifyActions.getPlaylist(key, {})); break; - case 'm3u': - store.dispatch(mopidyActions.getPlaylist(key, {})); - break; default: + store.dispatch(mopidyActions.getPlaylist(key, {})); break; } next(action); diff --git a/src/js/services/mopidy/middleware.js b/src/js/services/mopidy/middleware.js index 227f094c..91fd4f00 100755 --- a/src/js/services/mopidy/middleware.js +++ b/src/js/services/mopidy/middleware.js @@ -1,7 +1,7 @@ import ReactGA from 'react-ga'; import Mopidy from 'mopidy'; import { sha256 } from 'js-sha256'; -import { sampleSize, compact, chunk, find } from 'lodash'; +import { sampleSize, compact, chunk, find, uniq } from 'lodash'; import { i18n } from '../../locale'; import { generateGuid, @@ -860,7 +860,6 @@ const MopidyMiddleware = (function () { break; case 'MOPIDY_PLAY_PLAYLIST': { - console.debug(action) const playlist = store.getState().core.items[action.uri]; const { sortField, sortReverse } = getSortSelector(store.getState(), 'playlist_tracks'); if (playlist && playlist.tracks) { @@ -1423,7 +1422,7 @@ const MopidyMiddleware = (function () { // requires a Mopidy playlist object (with updates) request(store, 'playlists.lookup', { uri: action.key }) .then((response) => { - const mopidy_playlist = { ...response, name: action.name }; + const mopidy_playlist = { ...response, ...action.data }; request(store, 'playlists.save', { playlist: mopidy_playlist }) .then((response) => { @@ -1432,9 +1431,9 @@ const MopidyMiddleware = (function () { // Overwrite our playlist with the response to our save // This is essential to get the updated URI from Mopidy const playlist = { + ...action.data, ...store.getState().core.items[action.key], - uri: response.uri, - name: response.name, + ...response, }; // When we rename a playlist, the URI also changes to reflect the name change. @@ -2097,7 +2096,7 @@ const MopidyMiddleware = (function () { ...item, images: [], // Images is a playlist dependency, so this prevents triggering full load })); - const playlists_uris = arrayOf('uri', playlists); + const playlists_uris = uniq(arrayOf('uri', playlists)); const allUris = [...playlists_uris]; store.dispatch(coreActions.itemLoaded({ ...playlistGroup, @@ -2153,135 +2152,61 @@ const MopidyMiddleware = (function () { } case 'MOPIDY_GET_LIBRARY_PLAYLISTS': { store.dispatch(uiActions.startProcess(action.type, { notification: false })); + const scheme = action.uri.split(':')[0]; - // Built-in playlist support works differently to other providers - if (action.uri === 'm3u:playlists') { - request(store, 'playlists.asList') - .then((listResponse) => { - const libraryPlaylists = []; - const playlist_uris = arrayOf('uri', listResponse).filter( - (pUri) => (pUri.indexOf('m3u') > -1), - ); - store.dispatch( - uiActions.updateProcess( - action.type, - { - total: playlist_uris.length, - remaining: playlist_uris.length, - }, - ), - ); + request(store, 'playlists.asList').then((browseResponse) => { + const allUris = arrayOf('uri', browseResponse).filter((uri) => uri.startsWith(scheme)); + const unloadedUris = [...allUris]; - if (playlist_uris.length) { - playlist_uris.forEach((uri, index) => { - request(store, 'playlists.lookup', { uri }) - .then((response) => { - if (response) { - libraryPlaylists.push( - formatPlaylist({ - name: response.name, - uri: response.uri, - can_edit: uriSource(response.uri) === 'm3u', - last_modified: response.last_modified, - // By not including actual tracks they will be fetched when needed. We don't - // want these simple tracks because they don't contain duration, artist, etc. - tracks_total: response.tracks ? response.tracks.length : null, - }), - ); - } + store.dispatch( + uiActions.updateProcess( + action.type, + { + total: allUris.length, + remaining: unloadedUris.length, + }, + ), + ); - store.dispatch( - uiActions.updateProcess( - action.type, - { - remaining: playlist_uris.length - index - 1, - }, - ), - ); + const run = () => { + if (unloadedUris.length) { + const uri = unloadedUris.splice(0, 1)[0]; + const processor = store.getState().ui.processes[action.type]; - if (index === playlist_uris.length - 1) { - store.dispatch(coreActions.itemsLoaded(libraryPlaylists)); - store.dispatch(coreActions.libraryLoaded({ - uri: action.uri, - type: 'playlists', - items_uris: arrayOf('uri', libraryPlaylists), - })); - store.dispatch(uiActions.processFinished(action.type)); - } - }); - }); - } else { - store.dispatch(coreActions.libraryLoaded({ - uri: action.uri, - type: 'playlists', - items_uris: [], - })); - store.dispatch(uiActions.stopLoading('mopidy:library:playlists')); - store.dispatch(uiActions.processFinished(action.type)); + if (processor && processor.status === 'cancelling') { + store.dispatch(uiActions.processCancelled(action.type)); + store.dispatch(uiActions.stopLoading(action.uri)); + return; } - }); - } else { - request(store, 'library.browse', { uri: action.uri }) - .then((browseResponse) => { - const libraryPlaylists = []; + store.dispatch(uiActions.updateProcess(action.type, { remaining: unloadedUris.length })); - store.dispatch( - uiActions.updateProcess( - action.type, - { - total: browseResponse.length, - remaining: browseResponse.length, - }, - ), - ); + request(store, 'playlists.lookup', { uri }).then((lookupResponse) => { + if (lookupResponse) { + const playlist = formatPlaylist({ + name: lookupResponse.name, + uri: lookupResponse.uri, + can_edit: true, // TODO: Confirm whether some are uneditable?? + last_modified: lookupResponse.last_modified, + // Don't include simple tracks; they don't contain duration, artist, etc. + tracks_total: lookupResponse?.tracks?.length || null, + }); - if (browseResponse.length) { - browseResponse.forEach((playlist, index) => { - request(store, 'library.lookup', { uris: [playlist.uri] }) - .then((response) => { - if (response) { - libraryPlaylists.push( - formatPlaylist({ - name: playlist.name, - uri: playlist.uri, - can_edit: uriSource(playlist.uri) === 'm3u', - last_modified: playlist.last_modified, - tracks: formatTracks(response[playlist.uri]), - }), - ); - } + store.dispatch(coreActions.itemLoaded(playlist)); + } + run(); + }); + } else { + store.dispatch(uiActions.processFinished(action.type)); + store.dispatch(coreActions.libraryLoaded({ + uri: action.uri, + type: 'playlists', + items_uris: allUris, + })); + } + }; - store.dispatch( - uiActions.updateProcess( - action.type, - { - remaining: browseResponse.length - index - 1, - }, - ), - ); - - if (index === browseResponse.length - 1) { - store.dispatch(coreActions.itemsLoaded(libraryPlaylists)); - store.dispatch(coreActions.libraryLoaded({ - uri: action.uri, - type: 'playlists', - items_uris: arrayOf('uri', libraryPlaylists), - })); - store.dispatch(uiActions.processFinished(action.type)); - } - }); - }); - } else { - store.dispatch(coreActions.libraryLoaded({ - uri: action.uri, - type: 'playlists', - items_uris: [], - })); - store.dispatch(uiActions.stopLoading('mopidy:library:playlists')); - store.dispatch(uiActions.processFinished(action.type)); - } - }); - } + run(); + }); break; } diff --git a/src/js/services/spotify/actions.js b/src/js/services/spotify/actions.js index 474601fe..66a73519 100755 --- a/src/js/services/spotify/actions.js +++ b/src/js/services/spotify/actions.js @@ -1,4 +1,5 @@ import React from 'react'; +import { uniq } from 'lodash'; import { arrayOf } from '../../util/arrays'; import { generateGuid, @@ -506,13 +507,16 @@ export function getMood(uri, { forceRefetch } = {}) { getState, endpoint: plEndpoint, }).then((response) => { - playlists = [...playlists, ...formatPlaylists(response.playlists.items)]; + playlists = [ + ...playlists, + ...formatPlaylists(response.playlists.items.filter((item) => item)), + ]; if (response.playlists.next) { fetchPlaylists(response.playlists.next); } else { dispatch(coreActions.itemLoaded({ ...playlistGroup, - playlists_uris: arrayOf('uri', playlists), + playlists_uris: uniq(arrayOf('uri', playlists)), })); dispatch(coreActions.itemsLoaded(playlists)); dispatch(uiActions.stopLoading(loaderId)); @@ -1237,14 +1241,8 @@ export function createPlaylist(playlist) { }; } -export function savePlaylist(uri, name, description, is_public, is_collaborative, image) { +export function savePlaylist(uri, { image, ...data }) { return (dispatch, getState) => { - const data = { - name, - description, - public: is_public, - collaborative: is_collaborative, - }; const { spotify: { me: { diff --git a/src/js/util/selectors.js b/src/js/util/selectors.js index 933cd9d2..03782f69 100755 --- a/src/js/util/selectors.js +++ b/src/js/util/selectors.js @@ -121,6 +121,11 @@ const providers = { uri: 'jellyfin:playlists', title: i18n('services.jellyfin.title'), }, + { + scheme: 'ytmusic:', + uri: 'ytmusic:playlists', + title: i18n('services.youtube.title'), + }, ], albums: [ { diff --git a/src/js/views/Discover/Moods.js b/src/js/views/Discover/Moods.js index 7803b1a7..405a6af3 100644 --- a/src/js/views/Discover/Moods.js +++ b/src/js/views/Discover/Moods.js @@ -140,7 +140,7 @@ class Mood extends React.Component { items={moods} details={['playlists']} right_column={['source']} - getLink={(item) => `/discover/moods/${encodeUri(item.uri)}/${item.name}`} + getLink={(item) => `/discover/moods/${encodeUri(item.uri)}/${encodeURIComponent(item.name.replace('%', '').replace('/', ''))}`} thumbnail />
@@ -150,7 +150,7 @@ class Mood extends React.Component {
`/discover/moods/${encodeUri(item.uri)}/${item.name}`} + getLink={(item) => `/discover/moods/${encodeUri(item.uri)}/${encodeURIComponent(item.name.replace('%', '').replace('/', ''))}`} tile />
diff --git a/src/js/views/Modals/CreatePlaylist.js b/src/js/views/Modals/CreatePlaylist.js index 114f2365..109cc27b 100755 --- a/src/js/views/Modals/CreatePlaylist.js +++ b/src/js/views/Modals/CreatePlaylist.js @@ -154,6 +154,18 @@ const CreatePlaylist = () => { +