FileUpload.vue 16.7 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
164
      <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>
      <div v-if="fsStatus.import.status === 'started' || fsStatus.import.status === 'pending'"></div>
      <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>

165
166
167
168

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

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

export default {
190
  props: ["library", "defaultImportReference"],
191
192
  components: {
    FileUploadWidget,
193
194
195
    LibraryFilesTable,
    FsBrowser,
    FsLogs,
196
  },
197
198
199
  data() {
    let importReference = this.defaultImportReference || moment().format();
    this.$router.replace({ query: { import: importReference } });
200
201
    return {
      files: [],
202
      needsRefresh: false,
203
      currentTab: "uploads",
204
      uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"),
205
      importReference,
206
207
      isLoadingQuota: false,
      quotaStatus: null,
Eliot Berriot's avatar
Eliot Berriot committed
208
      uploads: {
209
210
211
212
        pending: 0,
        finished: 0,
        skipped: 0,
        errored: 0,
213
        objects: {}
214
      },
215
216
217
218
219
220
      processTimestamp: new Date(),
      fsStatus: null,
      fsPath: [],
      isLoadingFs: false,
      fsInterval: null,
      fsErrors: []
221
    };
222
  },
223
224
  created() {
    this.fetchStatus();
225
226
    if (this.$store.state.auth.availablePermissions['library']) {
      this.fetchFs(true)
Agate's avatar
Agate committed
227
      this.fsInterval = setInterval(() => {
228
229
230
        this.fetchFs(false)
      }, 5000);
    }
231
    this.fetchQuota();
232
233
234
    this.$store.commit("ui/addWebsocketEventHandler", {
      eventName: "import.status_updated",
      id: "fileUpload",
235
      handler: this.handleImportEvent
236
    });
237
    window.onbeforeunload = e => this.onBeforeUnload(e);
238
  },
239
240
241
242
243
  destroyed() {
    this.$store.commit("ui/removeWebsocketEventHandler", {
      eventName: "import.status_updated",
      id: "fileUpload"
    });
244
    window.onbeforeunload = null;
245
246
247
    if (this.fsInterval) {
      clearInterval(this.fsInterval)
    }
248
249
  },
  methods: {
250
251
252
253
254
255
256
257
    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;
    },
258
259
260
    fetchQuota () {
      let self = this
      self.isLoadingQuota = true
261
      axios.get('users/me/').then((response) => {
262
263
264
265
        self.quotaStatus = response.data.quota_status
        self.isLoadingQuota = false
      })
    },
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
297
    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()
    },
298
    inputFile(newFile, oldFile) {
299
300
301
302
303
304
305
306
      if (!newFile) {
        return
      }
      if (this.remainingSpace < newFile.size / (1000 * 1000)) {
        newFile.error = 'denied'
      } else {
        this.$refs.upload.active = true;
      }
307
    },
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
    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;
          });
      });
324
    },
325
326
    handleImportEvent(event) {
      let self = this;
Eliot Berriot's avatar
Eliot Berriot committed
327
      if (event.upload.import_reference != self.importReference) {
328
        return;
329
      }
330
      this.$nextTick(() => {
331
332
        self.uploads[event.old_status] -= 1;
        self.uploads[event.new_status] += 1;
333
334
        self.uploads.objects[event.upload.uuid] = event.upload;
        self.needsRefresh = true
335
      });
336
337
338
339
340
341
342
    },
    retry (files) {
      files.forEach((file) => {
        this.$refs.upload.update(file, {error: '', progress: '0.00'})
      })
      this.$refs.upload.active = true;

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

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