From 0d7d1e97570ef63a0bb100a5ca8c2f46983926f4 Mon Sep 17 00:00:00 2001 From: Genar Trias Ortiz Date: Sun, 11 Apr 2021 00:13:29 +0200 Subject: [PATCH 1/7] Local Filesystem media access This MR adds support to open local filesystem files. Using experimental [File System Access](https://wicg.github.io/file-system-access/). The idea is that you select a folder (or files for browsers not supporting File System Access API) and they are processed (read id3 tags), then the media just added must be persisted in database (among its fileHandler for read access for added files..) --- src/components/AddMediaModal/index.tsx | 27 ++++++++++++++ src/constants/ActionTypes.ts | 4 ++ src/entities/Media.ts | 2 +- src/mappers/mapToMedia.ts | 1 + src/sagas/providers/index.ts | 32 ++++++++++++++-- src/services/Filesystem/index.ts | 47 ++++++++++++++++++++++++ src/services/Filesystem/openDirectory.ts | 43 ++++++++++++++++++++++ src/services/ID3Tag/ID3TagService.ts | 15 ++++++-- src/services/Song/StreamUriService.ts | 30 ++++++++++++++- src/services/database/PouchdbAdapter.ts | 22 +++-------- 10 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 src/services/Filesystem/index.ts create mode 100644 src/services/Filesystem/openDirectory.ts diff --git a/src/components/AddMediaModal/index.tsx b/src/components/AddMediaModal/index.tsx index 40fdc830..3679aa83 100644 --- a/src/components/AddMediaModal/index.tsx +++ b/src/components/AddMediaModal/index.tsx @@ -8,6 +8,8 @@ import Modal from '../common/Modal' import Header from '../common/Header' import * as types from '../../constants/ActionTypes' +import openDialog from '../../services/Filesystem/openDirectory' + type Props = { showAddMediaModal: boolean, dispatch: Dispatch @@ -71,6 +73,31 @@ const AddMediaModal = (props: Props) => { + { /* Filesystem */ } +
+
+ Open local filesystem directory +
+ + + +
+ +
+
+
Youtube link
diff --git a/src/constants/ActionTypes.ts b/src/constants/ActionTypes.ts index 23c9a0ec..f3ff5049 100644 --- a/src/constants/ActionTypes.ts +++ b/src/constants/ActionTypes.ts @@ -152,3 +152,7 @@ export const IPFS_SONG_SAVED = 'IPFS_SONG_SAVED' export const SAVE_COLLECTION_FULLFILLED = 'SAVE_COLLECTION_FULLFILLED' export const SAVE_COLLECTION_FAILED = 'SAVE_COLLECTION_FAILED' + +// Filesystem +export const START_FILESYSTEM_FILES_PROCESSING = 'START_FILESYSTEM_FILES_PROCESSING' +export const FILESYSTEM_SONG_LOADED = 'FILESYSTEM_SONG_LOADED' diff --git a/src/entities/Media.ts b/src/entities/Media.ts index ed696d9b..f457cc35 100644 --- a/src/entities/Media.ts +++ b/src/entities/Media.ts @@ -3,7 +3,7 @@ import Album from './Album' import Artist from './Artist' type streamUri = { - uri: string, + uri: any, quality: string } diff --git a/src/mappers/mapToMedia.ts b/src/mappers/mapToMedia.ts index 3f7560f8..41b9811f 100644 --- a/src/mappers/mapToMedia.ts +++ b/src/mappers/mapToMedia.ts @@ -6,6 +6,7 @@ const mapToMedia = (collection: Array) => { } return collection.map((elem) => { + console.log('elem', elem) return rowToSong(elem.get()) }) } diff --git a/src/sagas/providers/index.ts b/src/sagas/providers/index.ts index e9bfe8c7..32998b66 100644 --- a/src/sagas/providers/index.ts +++ b/src/sagas/providers/index.ts @@ -10,14 +10,14 @@ import { } from 'redux-saga/effects' import { getAdapter } from '../../services/database' -import { getFileMetadata, metadataToSong } from '../../services/ID3Tag/ID3TagService' +import { getFileMetadata, metadataToSong, readFileMetadata } from '../../services/ID3Tag/ID3TagService' import { getSettings } from '../selectors' import { scanFolder } from '../../services/Ipfs/IpfsService' import CollectionService from '../../services/CollectionService' import YoutubeDlServerProvider from '../../providers/YoutubeDlServerProvider' import * as types from '../../constants/ActionTypes' -export function* startFolderScan(hash: string): any { +export function* startIpfsFolderScan(hash: string): any { const settings = yield select(getSettings) try { @@ -69,6 +69,29 @@ export function* startProvidersScan(): any { } } +// Handle filesystem adding +export function* startFilesystemProcess(action: any): any { + for (let i = 0; i < action.files.length; i++) { + const file = action.files[i] + const metadata = yield call(readFileMetadata, file) + const song = yield call(metadataToSong, metadata, file, 'filesystem') + + const adapter = getAdapter() + const collectionService = new CollectionService(new adapter()) + + // Save song + yield call(collectionService.save, song.id, song.toDocument()) + yield put({type: types.ADD_TO_COLLECTION, data: [song]}) + + yield put({ type: types.FILESYSTEM_SONG_LOADED, song }) + yield put({ + type: types.SEND_NOTIFICATION, + notification: song.title + ' - ' + song.artistName + ' saved', + level: 'info' + }) + } +} + // IPFS file scan Queue // Watcher export function* handleIPFSFileLoad(): any { @@ -82,7 +105,7 @@ export function* handleIPFSFileLoad(): any { const settings = yield select(getSettings) const metadata = yield call(getFileMetadata, file, settings) - const song = yield call(metadataToSong, metadata, file) + const song = yield call(metadataToSong, metadata, file.hash, 'ipfs') const adapter = getAdapter() const collectionService = new CollectionService(new adapter()) @@ -112,7 +135,7 @@ export function* handleIPFSFolderScan(): any { const { hash } = yield take(handleChannel) // 3- Note that we're using a blocking call try { - const files = yield call(startFolderScan, hash) + const files = yield call(startIpfsFolderScan, hash) for (let file of files) { if (file.type === 'dir') { @@ -148,6 +171,7 @@ function* startYoutubeDlScan(action: any) { function* providersSaga(): any { yield takeLatest(types.START_SCAN_SOURCES, startProvidersScan) yield takeEvery(types.START_YOUTUBE_DL_SERVER_SCAN, startYoutubeDlScan) + yield takeLatest(types.START_FILESYSTEM_FILES_PROCESSING, startFilesystemProcess) yield fork(handleIPFSFolderScan) yield fork(handleIPFSFileLoad) } diff --git a/src/services/Filesystem/index.ts b/src/services/Filesystem/index.ts new file mode 100644 index 00000000..e708d595 --- /dev/null +++ b/src/services/Filesystem/index.ts @@ -0,0 +1,47 @@ +declare const window: any; + + +class Filesystem { + hasFSAccess = 'chooseFileSystemEntries' in window || + 'showDirectoryPicker' in window + + getFileLegacy = () => { + const filePicker = document.getElementById('filePicker'); + + if (!filePicker) { + throw new Error('No file input#filePicker found') + } + + return new Promise((resolve, reject) => { + filePicker.onchange = (event) => { + const { files } = event.target as HTMLInputElement + if (files) { + resolve(files) + return + } + reject(new Error('AbortError')); + }; + + filePicker.click() + }); + } + + openDialog = (): Promise => { + if (!this.hasFSAccess) { + return this.getFileLegacy() + } + + const options = { + multiple: true + } + // For Chrome 86 and later... + if ('showDirectoryPicker' in window) { + return window.showDirectoryPicker({ mode: 'readwrite' }); + } + // For Chrome 85 and earlier... + return window.chooseFileSystemEntries(options) + } +} + +const singleton = new Filesystem() +export default singleton diff --git a/src/services/Filesystem/openDirectory.ts b/src/services/Filesystem/openDirectory.ts new file mode 100644 index 00000000..215ddb99 --- /dev/null +++ b/src/services/Filesystem/openDirectory.ts @@ -0,0 +1,43 @@ + +import Filesystem from './index' + +const openDialog = async (): Promise => { + const fs = Filesystem + const selectedFiles = await fs.openDialog() + const values = (selectedFiles.values && selectedFiles.values()) || selectedFiles + + console.log('selectedFiles: ', values) + + /** + const files: Array = [] + + for await (const entry of values) { + const file = await processSelectedFile(entry) + files.push(file.file) + } + */ + + return values +} + +const processSelectedFile = async (entry: any): Promise<{ + file: any, +}> => { + let file: any + console.log('entry: ', entry) + + // const writable = await entry.createWritable() + + if (entry.kind === 'file') { + file = await entry.getFile() + } else { + file = entry + } + const data = await new Response(file).text() + + return { + file: { name: entry.name, contents: data } + } +} + +export default openDialog diff --git a/src/services/ID3Tag/ID3TagService.ts b/src/services/ID3Tag/ID3TagService.ts index 4bed1b79..42152671 100644 --- a/src/services/ID3Tag/ID3TagService.ts +++ b/src/services/ID3Tag/ID3TagService.ts @@ -2,6 +2,12 @@ import * as musicMetadata from 'music-metadata-browser' import Media from '../../entities/Media' +export const readFileMetadata = async (file: any) => { + const metadata = await musicMetadata.parseBlob(file) + console.log('metadata: ', metadata) + return metadata +} + export const getFileMetadata = async (file: any, settings: any) => { const { proto, host, port } = settings.app.ipfs const metadata = await musicMetadata.fetchFromUrl(`${proto}://${host}:${port}/ipfs/${file.path}`) @@ -10,10 +16,11 @@ export const getFileMetadata = async (file: any, settings: any) => { export const metadataToSong = ( metadata: musicMetadata.IAudioMetadata, - file: any + fileUri: any, + service: string ): Media => { const song = new Media({ - title: metadata.common.title, + title: metadata.common.title || fileUri?.name, artistName: metadata.common.artist, // FIXME: genre is an array, we should extract only if its defined // genre: metadata.common.genre, @@ -21,11 +28,11 @@ export const metadataToSong = ( stream: [ { // FIXME: This could be anything - service: 'ipfs', + service: service, uris: [ { // FIXME: Make it configurable - uri: `${file.hash}`, + uri: fileUri, quality: 'unknown' } ] diff --git a/src/services/Song/StreamUriService.ts b/src/services/Song/StreamUriService.ts index c55a0b85..1cf1d7ac 100644 --- a/src/services/Song/StreamUriService.ts +++ b/src/services/Song/StreamUriService.ts @@ -11,10 +11,36 @@ export const getStreamUri = ( song.stream[providerNum].service === 'ipfs' ? `${proto}://${host}:${port}/ipfs/` : '' const streamUri = song && - song.stream && - song.stream[providerNum] && + song?.stream[providerNum] && song.stream.length ? song.stream[providerNum].uris[0].uri: null + console.log(song) + console.log(streamUri) + + verifyPermission(streamUri) + return streamUri ? prepend + streamUri : null } + +async function verifyPermission(fileHandle: any, readWrite = false) { + const options = {}; + if (readWrite) { + // options.mode = 'readwrite'; + } + + if (!fileHandle?.queryPermission) { + return + } + + // Check if permission was already granted. If so, return true. + if ((await fileHandle.queryPermission(options)) === 'granted') { + return true; + } + // Request permission. If the user grants permission, return true. + if ((await fileHandle.requestPermission(options)) === 'granted') { + return true; + } + // The user didn't grant permission, so return false. + return false; +} diff --git a/src/services/database/PouchdbAdapter.ts b/src/services/database/PouchdbAdapter.ts index ee949a0a..fb8cba40 100644 --- a/src/services/database/PouchdbAdapter.ts +++ b/src/services/database/PouchdbAdapter.ts @@ -34,23 +34,11 @@ export default class PouchdbAdapter implements IAdapter { return results } - removeMany(model: string, payload: Array): Promise { - const removes: Array = [] - payload.forEach((item) => { - const removePromise = this.getDocObj(model, item).then((doc) => doc.remove() ) - - removes.push(removePromise) - }) - - return new Promise((resolve, reject) => { - Promise.all(removes).then((results) => { - resolve(results) - }) - .catch((e) => { - logger.log('RxdbDatabase', e) - reject(e) - }) - }) + async removeMany(model: string, payload: Array): Promise { + for (let i = 0; i < payload.length; i++) { + const object = await this.getDocObj(model, payload[i]) + object.remove() + } } addItem = (model: string, item: any): Promise => { -- GitLab From e7d3b81b3b5f7b90d1f46911b16f16465275587b Mon Sep 17 00:00:00 2001 From: Genar Trias Ortiz Date: Sun, 11 Apr 2021 03:54:20 +0200 Subject: [PATCH 2/7] more fixes --- src/components/SongView/index.tsx | 46 ++++++++++++------------ src/sagas/player/index.ts | 2 +- src/sagas/providers/index.ts | 9 +++-- src/services/Filesystem/index.ts | 6 ++-- src/services/Filesystem/openDirectory.ts | 16 ++++----- src/services/ID3Tag/ID3TagService.ts | 5 +-- src/services/Song/StreamUriService.ts | 8 ++++- src/services/database/PouchdbAdapter.ts | 2 ++ 8 files changed, 53 insertions(+), 41 deletions(-) diff --git a/src/components/SongView/index.tsx b/src/components/SongView/index.tsx index e2aa9f09..b3fbb5d8 100644 --- a/src/components/SongView/index.tsx +++ b/src/components/SongView/index.tsx @@ -2,6 +2,7 @@ import { Dispatch } from 'redux' import { Link } from 'react-router-dom' import { Translate } from 'react-redux-i18n' import * as React from 'react' +import { AutoSizer } from 'react-virtualized' import { getDurationStr } from '../../utils/timeFormatter' import Button from '../common/Button' @@ -87,7 +88,7 @@ const SongView = (props: Props) => {
-
+
{ songFinder && song.media_type === 'video' && ( { }) }
- -
+ { + showLyrics && ( + setShowLyrics(false)} + /> + ) + } + + + {({ width }) => ( +
+ + { sameGenreSongs && + } + mediaItems={sameGenreSongs} + /> + } +
+ )} +
-
- -
- { sameGenreSongs && -
- } - mediaItems={sameGenreSongs} - /> -
- } - { - showLyrics && ( - setShowLyrics(false)} - /> - ) - }
) } diff --git a/src/sagas/player/index.ts b/src/sagas/player/index.ts index b9895427..e6819e28 100644 --- a/src/sagas/player/index.ts +++ b/src/sagas/player/index.ts @@ -26,7 +26,7 @@ export function* setCurrentPlayingStream(songId: string, providerNum: number): a // Getting the first stream URI, in the future will be choosen based on // priorities - const streamUri = getStreamUri(currentPlaying, settings, providerNum) + const streamUri = yield getStreamUri(currentPlaying, settings, providerNum) if (!streamUri) { return yield put({type: types.PLAY_NEXT}) diff --git a/src/sagas/providers/index.ts b/src/sagas/providers/index.ts index 32998b66..2ab0a7c3 100644 --- a/src/sagas/providers/index.ts +++ b/src/sagas/providers/index.ts @@ -73,8 +73,10 @@ export function* startProvidersScan(): any { export function* startFilesystemProcess(action: any): any { for (let i = 0; i < action.files.length; i++) { const file = action.files[i] - const metadata = yield call(readFileMetadata, file) - const song = yield call(metadataToSong, metadata, file, 'filesystem') + const metadata = yield call(readFileMetadata, file.file) + const song = yield call(metadataToSong, metadata, file.handler, 'filesystem') + + console.log('saving song: ', song) const adapter = getAdapter() const collectionService = new CollectionService(new adapter()) @@ -82,6 +84,7 @@ export function* startFilesystemProcess(action: any): any { // Save song yield call(collectionService.save, song.id, song.toDocument()) yield put({type: types.ADD_TO_COLLECTION, data: [song]}) + yield put({type: types.RECEIVE_COLLECTION, data: [song]}) yield put({ type: types.FILESYSTEM_SONG_LOADED, song }) yield put({ @@ -90,6 +93,8 @@ export function* startFilesystemProcess(action: any): any { level: 'info' }) } + + yield put({ type: types.RECREATE_INDEX }) } // IPFS file scan Queue diff --git a/src/services/Filesystem/index.ts b/src/services/Filesystem/index.ts index e708d595..763f713c 100644 --- a/src/services/Filesystem/index.ts +++ b/src/services/Filesystem/index.ts @@ -6,7 +6,7 @@ class Filesystem { 'showDirectoryPicker' in window getFileLegacy = () => { - const filePicker = document.getElementById('filePicker'); + const filePicker = document.getElementById('filePicker') if (!filePicker) { throw new Error('No file input#filePicker found') @@ -20,10 +20,10 @@ class Filesystem { return } reject(new Error('AbortError')); - }; + } filePicker.click() - }); + }) } openDialog = (): Promise => { diff --git a/src/services/Filesystem/openDirectory.ts b/src/services/Filesystem/openDirectory.ts index 215ddb99..1084fa85 100644 --- a/src/services/Filesystem/openDirectory.ts +++ b/src/services/Filesystem/openDirectory.ts @@ -6,37 +6,33 @@ const openDialog = async (): Promise => { const selectedFiles = await fs.openDialog() const values = (selectedFiles.values && selectedFiles.values()) || selectedFiles - console.log('selectedFiles: ', values) + console.log('selectedFiles: ', selectedFiles) - /** const files: Array = [] for await (const entry of values) { const file = await processSelectedFile(entry) - files.push(file.file) + files.push(file) } - */ - return values + return files } const processSelectedFile = async (entry: any): Promise<{ file: any, + handler: any }> => { let file: any - console.log('entry: ', entry) - - // const writable = await entry.createWritable() if (entry.kind === 'file') { file = await entry.getFile() } else { file = entry } - const data = await new Response(file).text() return { - file: { name: entry.name, contents: data } + file: file, + handler: entry } } diff --git a/src/services/ID3Tag/ID3TagService.ts b/src/services/ID3Tag/ID3TagService.ts index 42152671..c2e0ad8f 100644 --- a/src/services/ID3Tag/ID3TagService.ts +++ b/src/services/ID3Tag/ID3TagService.ts @@ -3,7 +3,8 @@ import * as musicMetadata from 'music-metadata-browser' import Media from '../../entities/Media' export const readFileMetadata = async (file: any) => { - const metadata = await musicMetadata.parseBlob(file) + const normFile = file.contents ? file.contents : file + const metadata = await musicMetadata.parseBlob(normFile) console.log('metadata: ', metadata) return metadata } @@ -17,7 +18,7 @@ export const getFileMetadata = async (file: any, settings: any) => { export const metadataToSong = ( metadata: musicMetadata.IAudioMetadata, fileUri: any, - service: string + service: string, ): Media => { const song = new Media({ title: metadata.common.title || fileUri?.name, diff --git a/src/services/Song/StreamUriService.ts b/src/services/Song/StreamUriService.ts index 1cf1d7ac..23deb205 100644 --- a/src/services/Song/StreamUriService.ts +++ b/src/services/Song/StreamUriService.ts @@ -1,6 +1,6 @@ import Media from '../../entities/Media' -export const getStreamUri = ( +export const getStreamUri = async ( song: Media, settings: any, providerNum: number @@ -20,6 +20,12 @@ export const getStreamUri = ( verifyPermission(streamUri) + if (streamUri?.getFile) { + const file = await streamUri.getFile() + console.log('file:', file) + return URL.createObjectURL(file) + } + return streamUri ? prepend + streamUri : null } diff --git a/src/services/database/PouchdbAdapter.ts b/src/services/database/PouchdbAdapter.ts index fb8cba40..8599f5c6 100644 --- a/src/services/database/PouchdbAdapter.ts +++ b/src/services/database/PouchdbAdapter.ts @@ -74,6 +74,8 @@ export default class PouchdbAdapter implements IAdapter { }, {type: model}, {attachments: true}) if (result) { + console.log('getAll result: ', result) + // FIXME: This elem.key should be elem.value maybe? resolve(result.rows.map((elem: any) => elem.key)) } -- GitLab From 6faf5124f7257fabd78bfe088aa8b39bcd15a028 Mon Sep 17 00:00:00 2001 From: Genar Trias Ortiz Date: Sun, 11 Apr 2021 04:14:51 +0200 Subject: [PATCH 3/7] moving player controls to context menu --- src/components/Player/ContextualMenu.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/Player/ContextualMenu.tsx b/src/components/Player/ContextualMenu.tsx index d7bd2ecd..f0610fce 100644 --- a/src/components/Player/ContextualMenu.tsx +++ b/src/components/Player/ContextualMenu.tsx @@ -11,6 +11,7 @@ import ToggleMiniQueueButton from '../Buttons/ToggleMiniQueueButton' import AddNewMediaButton from '../Buttons/AddNewMediaButton' import VolumeControl from './VolumeControl' import * as types from '../../constants/ActionTypes' +import Controls from './Controls' type MenuProps = { app: app, @@ -196,6 +197,19 @@ const ContextualMenu = (props: MenuProps) => { } + + +
+ props.dispatch({type: types.PLAY_PREV}) } + isPlaying={props.player.playing} + mqlMatch={true} + playPause={() => props.dispatch({ type: types.TOGGLE_PLAYING }) } + playNext={() => props.dispatch({type: types.PLAY_NEXT})} + dispatch={props.dispatch} + /> +
+
) -- GitLab From 70979663537b3d9d1e238f54070c81c99f629e4d Mon Sep 17 00:00:00 2001 From: Genar Trias Ortiz Date: Mon, 12 Apr 2021 00:05:49 +0200 Subject: [PATCH 4/7] successfully saving fileHandlers to being used over reloads --- package.json | 1 + src/components/AddMediaModal/index.tsx | 6 +-- src/components/common/Button/index.tsx | 2 + src/sagas/providers/index.ts | 5 ++- src/services/Filesystem/FileManager.ts | 56 ++++++++++++++++++++++++ src/services/Filesystem/openDirectory.ts | 39 ----------------- src/services/Song/StreamUriService.ts | 25 ++++++----- yarn.lock | 5 +++ 8 files changed, 86 insertions(+), 53 deletions(-) create mode 100644 src/services/Filesystem/FileManager.ts delete mode 100644 src/services/Filesystem/openDirectory.ts diff --git a/package.json b/package.json index 95ec2dc0..f74b8a5d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "formik": "^1.3.1", "fs-extra": "3.0.1", "html-webpack-plugin": "^3.2.0", + "idb-keyval": "^5.0.4", "ipfs-http-client": "^38.0.0", "jest": "^24.9.0", "jest-runner-eslint": "^0.6.0", diff --git a/src/components/AddMediaModal/index.tsx b/src/components/AddMediaModal/index.tsx index 3679aa83..b1905f28 100644 --- a/src/components/AddMediaModal/index.tsx +++ b/src/components/AddMediaModal/index.tsx @@ -8,7 +8,7 @@ import Modal from '../common/Modal' import Header from '../common/Header' import * as types from '../../constants/ActionTypes' -import openDialog from '../../services/Filesystem/openDirectory' +import fileManager from '../../services/Filesystem/FileManager' type Props = { showAddMediaModal: boolean, @@ -79,14 +79,14 @@ const AddMediaModal = (props: Props) => { Open local filesystem directory - +