FileUpload.vue 16.6 KB
Newer Older
1
  <template>
Agate's avatar
Agate committed
2
  <div class="component-file-upload">
3
    <div class="ui top attached tabular menu">
Agate's avatar
Agate committed
4
      <a href="" :class="['item', {active: currentTab === 'uploads'}]" @click.prevent="currentTab = 'uploads'">
5
        <translate translate-context="Content/Library/Tab.Title/Short">Uploading</translate>
6
7
8
        <div v-if="files.length === 0" class="ui label">
          0
        </div>
Agate's avatar
Agate committed
9
        <div v-else-if="files.length > uploadedFilesCount + erroredFilesCount" class="ui warning label">
10
11
          {{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }}
        </div>
Agate's avatar
Agate committed
12
        <div v-else :class="['ui', {'success': erroredFilesCount === 0}, {'danger': erroredFilesCount > 0}, 'label']">
13
14
15
          {{ uploadedFilesCount + erroredFilesCount }}/{{ files.length }}
        </div>
      </a>
Agate's avatar
Agate committed
16
      <a href="" :class="['item', {active: currentTab === 'processing'}]" @click.prevent="currentTab = 'processing'">
17
        <translate translate-context="Content/Library/Tab.Title/Short">Processing</translate>
18
19
20
        <div v-if="processableFiles === 0" class="ui label">
          0
        </div>
Agate's avatar
Agate committed
21
        <div v-else-if="processableFiles > processedFilesCount" class="ui warning label">
22
23
          {{ processedFilesCount }}/{{ processableFiles }}
        </div>
Agate's avatar
Agate committed
24
        <div v-else :class="['ui', {'success': uploads.errored === 0}, {'danger': uploads.errored > 0}, 'label']">
25
26
27
28
29
          {{ processedFilesCount }}/{{ processableFiles }}
        </div>
      </a>
    </div>
    <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'uploads'}]">
30
      <div :class="['ui', {loading: isLoadingQuota}, 'container']">
Agate's avatar
Agate committed
31
        <div :class="['ui', {red: remainingSpace === 0}, {warning: remainingSpace > 0 && remainingSpace <= 50}, 'small', 'statistic']">
32
33
34
35
36
37
38
          <div class="label">
            <translate translate-context="Content/Library/Paragraph">Remaining storage space</translate>
          </div>
          <div class="value">
            {{ remainingSpace * 1000 * 1000 | humanSize}}
          </div>
        </div>
39
40
41
42
43
44
45
46
47
48
49
50
51
        <div class="ui divider"></div>
        <h2 class="ui header"><translate translate-context="Content/Library/Title/Verb">Upload music from your local storage</translate></h2>
        <div class="ui message">
          <p><translate translate-context="Content/Library/Paragraph">You are about to upload music to your library. Before proceeding, please ensure that:</translate></p>
          <ul>
            <li v-if="library.privacy_level != 'me'">
              <translate translate-context="Content/Library/List item">You are not uploading copyrighted content in a public library, otherwise you may be infringing the law</translate>
            </li>
            <li>
              <translate translate-context="Content/Library/List item">The music files you are uploading are tagged properly.</translate>&nbsp;
              <a href="http://picard.musicbrainz.org/" target='_blank'><translate translate-context="Content/Library/Link">We recommend using Picard for that purpose.</translate></a>
            </li>
            <li>
52
              <translate translate-context="Content/Library/List item">The music files you are uploading are in OGG, Flac, MP3 or AIFF format</translate>
53
54
55
            </li>
          </ul>
        </div>
56
57
58
59
60
61
        <file-upload-widget
          :class="['ui', 'icon', 'basic', 'button']"
          :post-action="uploadUrl"
          :multiple="true"
          :data="uploadData"
          :drop="true"
62
          :extensions="supportedExtensions"
63
64
65
66
67
          v-model="files"
          name="audio_file"
          :thread="1"
          @input-file="inputFile"
          ref="upload">
68
          <i class="upload icon"></i>&nbsp;
69
          <translate translate-context="Content/Library/Paragraph/Call to action">Click to select files to upload or drag and drop files or directories</translate>
70
71
          <br />
          <br />
72
          <i><translate translate-context="Content/Library/Paragraph" :translate-params="{extensions: supportedExtensions.join(', ')}">Supported extensions: %{ extensions }</translate></i>
73
74
        </file-upload-widget>
      </div>
75
76
77
78
79
      <div v-if="files.length > 0" class="table-wrapper">
        <div class="ui hidden divider"></div>
        <table class="ui unstackable table">
          <thead>
            <tr>
80
              <th class="ten wide"><translate translate-context="Content/Library/Table.Label">Filename</translate></th>
Eliot Berriot's avatar
Eliot Berriot committed
81
82
              <th><translate translate-context="Content/*/*/Noun">Size</translate></th>
              <th><translate translate-context="*/*/*">Status</translate></th>
83
84
85
86
87
88
89
90
91
92
93
              <th><translate translate-context="*/*/*">Actions</translate></th>
            </tr>
            <tr v-if="retryableFiles.length > 1">
              <th class="ten wide"></th>
              <th></th>
              <th></th>
              <th>
                <button class="ui right floated small basic button" @click.prevent="retry(retryableFiles)">
                  <translate translate-context="Content/Library/Table">Retry failed uploads</translate>
                </button>
              </th>
94
95
96
97
98
99
100
101
            </tr>
          </thead>
          <tbody>
            <tr v-for="(file, index) in sortedFiles" :key="file.id">
              <td :title="file.name">{{ file.name | truncate(60) }}</td>
              <td>{{ file.size | humanSize }}</td>
              <td>
                <span v-if="file.error" class="ui tooltip" :data-tooltip="labels.tooltips[file.error]">
Agate's avatar
Agate committed
102
                  <span class="ui danger icon label">
103
104
                    <i class="question circle outline icon" /> {{ file.error }}
                  </span>
105
                </span>
Agate's avatar
Agate committed
106
                <span v-else-if="file.success" class="ui success label">
107
                  <translate translate-context="Content/Library/Table" key="1">Uploaded</translate>
108
                </span>
Agate's avatar
Agate committed
109
                <span v-else-if="file.active" class="ui warning label">
110
                  <translate translate-context="Content/Library/Table" key="2">Uploading…</translate>
111
112
                  ({{ parseInt(file.progress) }}%)
                </span>
113
114
115
116
117
118
119
120
121
122
123
124
125
                <span v-else class="ui label"><translate translate-context="Content/Library/*/Short" key="3">Pending</translate></span>
              </td>
              <td>
                <template v-if="file.error">
                  <button
                    class="ui tiny basic icon right floated button"
                    :title="labels.retry"
                    @click.prevent="retry([file])"
                    v-if="retryableFiles.indexOf(file) > -1">
                    <i class="redo icon"></i>
                  </button>
                </template>
                <template v-else-if="!file.success">
Agate's avatar
Agate committed
126
                  <button class="ui tiny basic danger icon right floated button" @click.prevent="$refs.upload.remove(file)"><i class="delete icon"></i></button>
127
128
129
130
131
132
                </template>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
      <div class="ui divider"></div>
      <h2 class="ui header"><translate translate-context="Content/Library/Title/Verb">Import music from your server</translate></h2>
      <div v-if="fsErrors.length > 0" role="alert" class="ui negative message">
        <h3 class="header"><translate translate-context="Content/*/Error message.Title">Error while launching import</translate></h3>
        <ul class="list">
          <li v-for="error in fsErrors">{{ error }}</li>
        </ul>
      </div>
      <fs-browser
        v-model="fsPath"
        @import="importFs"
        :loading="isLoadingFs"
        :data="fsStatus"></fs-browser>
      <template v-if="fsStatus && fsStatus.import">
        <h3 class="ui header"><translate translate-context="Content/Library/Title/Verb">Import status</translate></h3>
        <p v-if="fsStatus.import.reference != importReference">
          <translate translate-context="Content/Library/Paragraph">Results of your previous import:</translate>
        </p>
        <p v-else>
          <translate translate-context="Content/Library/Paragraph">Results of your import:</translate>
        </p>

        <button
          class="ui button"
          @click="cancelFsScan"
          v-if="fsStatus.import.status === 'started' || fsStatus.import.status === 'pending'">
          <translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
        </button>
        <fs-logs :data="fsStatus.import"></fs-logs>
      </template>

164
165
166
167

    </div>
    <div :class="['ui', 'bottom', 'attached', 'segment', {hidden: currentTab != 'processing'}]">
      <library-files-table
168
        :needs-refresh="needsRefresh"
169
        ordering-config-name="library.detail.upload"
170
        @fetch-start="needsRefresh = false"
171
        :filters="{import_reference: importReference}"
Eliot Berriot's avatar
Eliot Berriot committed
172
        :custom-objects="Object.values(uploads.objects)"></library-files-table>
173
174
175
176
177
    </div>
  </div>
</template>

<script>
178
import _ from "@/lodash"
179
180
181
182
import $ from "jquery";
import axios from "axios";
import logger from "@/logging";
import FileUploadWidget from "./FileUploadWidget";
183
184
import FsBrowser from "./FsBrowser";
import FsLogs from "./FsLogs";
185
186
import LibraryFilesTable from "@/views/content/libraries/FilesTable";
import moment from "moment";
187
188

export default {
189
  props: ["library", "defaultImportReference"],
190
191
  components: {
    FileUploadWidget,
192
193
194
    LibraryFilesTable,
    FsBrowser,
    FsLogs,
195
  },
196
197
198
  data() {
    let importReference = this.defaultImportReference || moment().format();
    this.$router.replace({ query: { import: importReference } });
199
200
    return {
      files: [],
201
      needsRefresh: false,
202
      currentTab: "uploads",
203
      uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"),
204
      importReference,
205
206
      isLoadingQuota: false,
      quotaStatus: null,
Eliot Berriot's avatar
Eliot Berriot committed
207
      uploads: {
208
209
210
211
        pending: 0,
        finished: 0,
        skipped: 0,
        errored: 0,
212
        objects: {}
213
      },
214
215
216
217
218
219
      processTimestamp: new Date(),
      fsStatus: null,
      fsPath: [],
      isLoadingFs: false,
      fsInterval: null,
      fsErrors: []
220
    };
221
  },
222
223
  created() {
    this.fetchStatus();
224
225
    if (this.$store.state.auth.availablePermissions['library']) {
      this.fetchFs(true)
Agate's avatar
Agate committed
226
      this.fsInterval = setInterval(() => {
227
228
229
        this.fetchFs(false)
      }, 5000);
    }
230
    this.fetchQuota();
231
232
233
    this.$store.commit("ui/addWebsocketEventHandler", {
      eventName: "import.status_updated",
      id: "fileUpload",
234
      handler: this.handleImportEvent
235
    });
236
    window.onbeforeunload = e => this.onBeforeUnload(e);
237
  },
238
239
240
241
242
  destroyed() {
    this.$store.commit("ui/removeWebsocketEventHandler", {
      eventName: "import.status_updated",
      id: "fileUpload"
    });
243
    window.onbeforeunload = null;
244
245
246
    if (this.fsInterval) {
      clearInterval(this.fsInterval)
    }
247
248
  },
  methods: {
249
250
251
252
253
254
255
256
    onBeforeUnload(e = {}) {
      const returnValue = ('This page is asking you to confirm that you want to leave - data you have entered may not be saved.');
      if (!this.hasActiveUploads) return null;
      Object.assign(e, {
        returnValue,
      });
      return returnValue;
    },
257
258
259
    fetchQuota () {
      let self = this
      self.isLoadingQuota = true
260
      axios.get('users/me/').then((response) => {
261
262
263
264
        self.quotaStatus = response.data.quota_status
        self.isLoadingQuota = false
      })
    },
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
    fetchFs (updateLoading) {
      let self = this
      if (updateLoading) {
        self.isLoadingFs = true
      }
      axios.get('libraries/fs-import', {params: {path: this.fsPath.join('/')}}).then((response) => {
        self.fsStatus = response.data
        if (updateLoading) {
          self.isLoadingFs = false
        }
      })
    },
    importFs () {
      let self = this
      self.isLoadingFs = true
      let payload = {
        path: this.fsPath.join('/'),
        library: this.library.uuid,
        import_reference: this.importReference,
      }
      axios.post('libraries/fs-import', payload).then((response) => {
        self.fsStatus = response.data
        self.isLoadingFs = false
      }, error => {
        self.isLoadingFs = false
        self.fsErrors = error.backendErrors
      })
    },
    async cancelFsScan () {
      await axios.delete('libraries/fs-import')
      this.fetchFs()
    },
297
    inputFile(newFile, oldFile) {
298
299
300
301
302
303
304
305
      if (!newFile) {
        return
      }
      if (this.remainingSpace < newFile.size / (1000 * 1000)) {
        newFile.error = 'denied'
      } else {
        this.$refs.upload.active = true;
      }
306
    },
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
    fetchStatus() {
      let self = this;
      let statuses = ["pending", "errored", "skipped", "finished"];
      statuses.forEach(status => {
        axios
          .get("uploads/", {
            params: {
              import_reference: self.importReference,
              import_status: status,
              page_size: 1
            }
          })
          .then(response => {
            self.uploads[status] = response.data.count;
          });
      });
323
    },
324
325
    handleImportEvent(event) {
      let self = this;
Eliot Berriot's avatar
Eliot Berriot committed
326
      if (event.upload.import_reference != self.importReference) {
327
        return;
328
      }
329
      this.$nextTick(() => {
330
331
        self.uploads[event.old_status] -= 1;
        self.uploads[event.new_status] += 1;
332
333
        self.uploads.objects[event.upload.uuid] = event.upload;
        self.needsRefresh = true
334
      });
335
336
337
338
339
340
341
    },
    retry (files) {
      files.forEach((file) => {
        this.$refs.upload.update(file, {error: '', progress: '0.00'})
      })
      this.$refs.upload.active = true;

342
    }
343
344
  },
  computed: {
345
346
347
    supportedExtensions () {
      return this.$store.state.ui.supportedExtensions
    },
348
    labels() {
349
      let denied = this.$pgettext('Content/Library/Help text',
Allan Nordhøy's avatar
Allan Nordhøy committed
350
        "Upload denied, ensure the file is not too big and that you have not reached your quota"
351
      );
352
      let server = this.$pgettext('Content/Library/Help text',
Allan Nordhøy's avatar
Allan Nordhøy committed
353
        "Cannot upload this file, ensure it is not too big"
354
      );
355
      let network = this.$pgettext('Content/Library/Help text',
356
        "A network error occurred while uploading this file"
357
      );
358
      let timeout = this.$pgettext('Content/Library/Help text', "Upload timeout, please try again");
359
      let extension = this.$pgettext('Content/Library/Help text',
360
361
        "Invalid file type, ensure you are uploading an audio file. Supported file extensions are %{ extensions }"
      );
362
363
364
365
366
      return {
        tooltips: {
          denied,
          server,
          network,
367
          timeout,
368
          retry: this.$pgettext('*/*/*/Verb', "Retry"),
369
370
371
          extension: this.$gettextInterpolate(extension, {
            extensions: this.supportedExtensions.join(", ")
          })
372
        }
373
      };
374
    },
375
376
377
378
    uploadedFilesCount() {
      return this.files.filter(f => {
        return f.success;
      }).length;
379
    },
380
381
382
383
    uploadingFilesCount() {
      return this.files.filter(f => {
        return !f.success && !f.error;
      }).length;
384
    },
385
386
387
388
    erroredFilesCount() {
      return this.files.filter(f => {
        return f.error;
      }).length;
389
    },
390
391
392
393
394
    retryableFiles () {
      return this.files.filter(f => {
        return f.error;
      });
    },
395
396
397
398
399
400
401
402
    processableFiles() {
      return (
        this.uploads.pending +
        this.uploads.skipped +
        this.uploads.errored +
        this.uploads.finished +
        this.uploadedFilesCount
      );
403
    },
404
405
406
407
    processedFilesCount() {
      return (
        this.uploads.skipped + this.uploads.errored + this.uploads.finished
      );
408
    },
409
    uploadData: function() {
410
      return {
411
412
413
        library: this.library.uuid,
        import_reference: this.importReference
      };
414
    },
415
    sortedFiles() {
416
      // return errored files on top
417
418
419

      return _.sortBy(this.files.map(f => {
        let statusIndex = 0
420
        if (f.errored) {
421
          statusIndex = -1
422
423
        }
        if (f.success) {
424
          statusIndex = 1
425
        }
426
427
428
        f.statusIndex = statusIndex
        return f
      }), ['statusIndex', 'name'])
429
430
431
    },
    hasActiveUploads () {
      return this.sortedFiles.filter((f) => { return f.active }).length > 0
432
433
434
435
436
437
438
439
440
441
    },
    remainingSpace () {
      if (!this.quotaStatus) {
        return 0
      }
      return Math.max(0, this.quotaStatus.remaining - (this.uploadedSize / (1000 * 1000)))
    },
    uploadedSize () {
      let uploaded = 0
      this.files.forEach((f) => {
442
443
444
        if (!f.error) {
          uploaded += f.size * (f.progress / 100)
        }
445
446
      })
      return uploaded
447
448
449
    }
  },
  watch: {
450
451
    importReference: _.debounce(function() {
      this.$router.replace({ query: { import: this.importReference } });
452
453
454
455
456
    }, 500),
    remainingSpace (newValue) {
      if (newValue <= 0) {
        this.$refs.upload.active = false;
      }
457
458
459
460
461
    },
    'uploads.finished' (v, o) {
      if (v > o) {
        this.$emit('uploads-finished', v - o)
      }
462
463
464
    },
    "fsPath" () {
      this.fetchFs(true)
465
    }
466
  }
467
};
468
</script>