torrent detail page + file list

This commit is contained in:
Daan Wijns 2020-05-23 19:06:28 +02:00
parent 7819abb1e7
commit 09832e6f1a
15 changed files with 554 additions and 323 deletions

View file

@ -1,71 +1,130 @@
# VueTorrent
The sleekest looking WEBUI for qBittorrent made with Vuejs!
> Vue, qBitorrent, Vuetify
## Screenshots
<p align="center">
<a href="https://i.imgur.com/vPBcrK4.png"><img src="https://i.imgur.com/vPBcrK4.png" title="Desktop" alt="Desktop Screenshot" ></a>
<a href="https://imgur.com/xgwECT2.png"><img src="https://imgur.com/xgwECT2.png" title="Desktop" alt="Desktop Screenshot" ></a>
</p>
<p align="center">
<p align="center">
<a href="https://i.imgur.com/SUOEyy9.png"><img src="https://i.imgur.com/SUOEyy9.png" title="Mobile" alt="Mobile Screenshot" width="320" height="540"></a>
</p>
## Installation
- Download & Unzip the latest release
- Point your Alternate WEBUI location to it
## Development
- clone the repo
- npm install
- npm run serve
## Features
- viewing sessions status ( down / upload speed, session uploaded / downloaded )
- adding / removing / pausing / resuming torrents
- sorting by every property shown!
* mobile friendly! (maybe not for thousands of torrents...)
- works on QBittorrent V4.2 and later
## Contributing
I'll gladly accept help/pull requests & advice! (this is my first project of this nature, pls be kind 😛 ).
## FAQ
- **Why build this??**
- **Why build this??**
* Why not? Most WebUI's look very dated and now it's no longer necessary to search for a remote control app!
## Support
Reach out to me at one of the following places!
- <a href="https://m.me/WijnsDaan" target="_blank">`Facebook Messenger`</a>
- <a href="https://m.me/WijnsDaan" target="_blank">`Facebook Messenger`</a>
* Open up an issue 😛
[<img src="https://cdn.buymeacoffee.com/buttons/lato-blue.png" alt="drawing" width="180"/>](https://www.buymeacoffee.com/wdaan "Buy me a coffee")
[<img src="https://cdn.buymeacoffee.com/buttons/lato-blue.png" alt="drawing" width="180"/>](https://www.buymeacoffee.com/wdaan "Buy me a coffee")
## Credits
- Dashboard design heavily inspired by: '[Net Ninja - Vuetify](https://github.com/iamshaunjp/vuetify-playlist)'.
Also check out The Net Ninja's Youtube Channel.
* This repo '[CzBiX qb-web ](https://github.com/CzBiX/qb-web)'
- Dashboard design heavily inspired by: '[Net Ninja - Vuetify](https://github.com/iamshaunjp/vuetify-playlist)'.
Also check out The Net Ninja's Youtube Channel.
* This repo '[CzBiX qb-web ](https://github.com/CzBiX/qb-web)'3.0)

379
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@
"vue-observe-visibility": "^0.4.6",
"vue-router": "^3.2.0",
"vue-toastification": "^1.7.1",
"vue2-perfect-scrollbar": "^1.5.0",
"vuetify": "^2.2.11",
"vuex": "^3.4.0",
"vuex-persist": "^2.2.0"

View file

@ -1,9 +1,10 @@
<template>
<v-app :style="{ backgroundColor : background }" >
<AddModal />
<Navbar v-if="authenticated" />
<v-container fill-height fill-width>
<v-content>
<TorrentDetailModal/>
<Navbar v-if="isAuthenticated" />
<v-container class="pa-4">
<v-content fill-height fill-width>
<router-view></router-view>
</v-content>
</v-container>
@ -13,6 +14,7 @@
<script>
import { mapState, mapGetters } from 'vuex'
import Navbar from '@/components/Navbar.vue'
import {isAuthenticated} from '@/services/auth.js'
export default {
components: { Navbar },
@ -20,14 +22,22 @@ export default {
data() {
return {}
},
methods: {
async getAuth(){
return await isAuthenticated()
}
},
computed: {
...mapState(['authenticated', 'rid', 'mainData', 'preferences']),
...mapState(['rid', 'mainData', 'preferences']),
...mapGetters(['getTheme']),
theme() {
return this.getTheme() ? 'dark' : 'light'
},
background(){
return this.$vuetify.theme.themes[this.theme].background
},
isAuthenticated(){
return this.getAuth()
}
}
}

View file

@ -0,0 +1,203 @@
<template>
<v-dialog max-width="800px" v-model="dialog" scrollable>
<v-card
v-if="torrent"
style="min-height: 400px; overflow:hidden !important"
>
<v-container :class="`pa-0 project ${torrent.state}`">
<v-card-title class="justify-center primary">
<h2 class="white--text">Torrent Detail</h2>
</v-card-title>
<v-tabs v-model="tab" background-color="primary" fixed-tabs>
<v-tab v-for="item in items" :key="item.tab">
{{ item.tab }}
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item>
<v-card flat>
<v-card-text style="font-size: 1.2em">
<v-flex>
<span class="grey--text">Torrent title </span>
<span class="torrentmodaltext--text ">{{
torrent.name
}}</span>
</v-flex>
<v-flex class="mt-2">
<span class="grey--text">hash </span>
<span class="torrentmodaltext--text ">{{
torrent.hash
}}</span>
</v-flex>
<v-flex class="mt-2">
<span class="grey--text">Size </span>
<span class="torrentmodaltext--text ">
{{ torrent.size }}
</span>
</v-flex>
<v-flex class="mt-2">
<span class="grey--text">Done: </span>
<span class="torrentmodaltext--text ">
{{ torrent.dloaded }}
</span>
</v-flex>
<v-flex class="mt-2">
<span class="grey--text">Download </span>
<span class="torrentmodaltext--text ">{{
torrent.dlspeed
}}</span>
</v-flex>
<v-flex class="mt-2">
<span class="grey--text">Upload </span>
<span class="torrentmodaltext--text ">{{
torrent.upspeed
}}</span>
</v-flex>
<v-flex class="mt-2">
<span class="grey--text">ETA </span>
<span class="torrentmodaltext--text ">{{
torrent.eta
}}</span>
</v-flex>
<v-flex class="mt-2">
<span class="grey--text">Peers </span>
<span class="torrentmodaltext--text ">
{{ torrent.num_leechs
}}<span class="grey--text"
>/{{ torrent.available_peers }}</span
>
</span>
</v-flex>
<v-flex class="mt-2">
<span class=" grey--text">Seeds </span>
<span class="torrentmodaltext--text ">
{{ torrent.num_seeds
}}<span class="grey--text"
>/{{ torrent.available_seeds }}</span
>
</span>
</v-flex>
<v-flex class="mt-2">
<span class=" grey--text">Ratio </span>
<span class="torrentmodaltext--text ">
{{ torrent.ratio }}%
</span>
</v-flex>
<v-flex>
<span class="grey--text">Status </span>
<v-chip
small
:class="`${torrent.state} white--text my-2 caption`"
>{{ torrent.state }}</v-chip
>
</v-flex>
<v-flex>
<v-progress-linear
height="5"
stream
rounded
color="cyan darken-1"
background-color="cyan lighten-3"
:buffer-value="torrent.progress"
></v-progress-linear>
</v-flex>
</v-card-text>
</v-card>
</v-tab-item>
<v-tab-item>
<v-card flat class="scrollbar" style="overflow-y: auto !important;">
<perfect-scrollbar>
<v-card-text style="max-height: 500px; min-height: 400px;">
<v-treeview
v-model="tree"
:items="fileTree"
activatable
item-key="name"
open-on-click
>
<template v-slot:prepend="{ item, open }">
<v-icon v-if="!item.icon">
{{ open ? "mdi-folder-open" : "mdi-folder" }}
</v-icon>
<v-icon v-else>
{{ item.icon }}
</v-icon>
</template>
<template v-slot:append="{ item }">
<span v-if="!item.icon">
{{ item.children.length }} Files
</span>
<div v-else>
<span>[{{ item.size }}]</span>
<span class="ml-4">{{ item.progress }}%</span>
</div>
</template>
</v-treeview>
</v-card-text>
</perfect-scrollbar>
</v-card>
</v-tab-item>
</v-tabs-items>
</v-container>
</v-card>
</v-dialog>
</template>
<script>
import Modal from "@/mixins/Modal";
import { mapGetters } from "vuex";
import qbit from "@/services/qbit";
import { treeify } from "@/helpers";
export default {
name: "TorrentDetailModal",
mixins: [Modal],
data() {
return {
tab: null,
items: [{ tab: "Info" }, { tab: "Content" }],
tempFileTree: [],
fileTree: [],
tree: [],
};
},
methods: {
async getTorrentProperties() {
const { data } = await qbit.getTorrentFiles(this.hash);
// console.log(data)
return data;
},
async getTorrentFiles() {
const { data } = await qbit.getTorrentFiles(this.hash);
this.fileTree = treeify(data);
},
},
computed: {
...mapGetters(["getTorrent"]),
hash() {
return this.$store.state.selectedDetailTorrent;
},
torrent() {
return this.getTorrent(this.hash);
},
torrentFiles() {
let arr = this.getTorrentFiles();
console.log(arr);
return arr.map(this.addnode);
},
},
watch: {
dialog(visible) {
if (visible) {
this.getTorrentProperties();
this.getTorrentFiles();
} else {
this.fileTree = [];
}
},
},
};
</script>

View file

@ -226,7 +226,7 @@ export default {
animations: {
enabled: false,
dynamicAnimation: {
speed: 2000
speed: 1000
}
}
},
@ -249,18 +249,6 @@ export default {
}
}
},
series: [
{
name: 'upload',
type: 'area',
data: this.$store.state.upload_data
},
{
name: 'download',
type: 'area',
data: this.$store.state.download_data
}
],
chartInterval: null
}
},
@ -301,16 +289,24 @@ export default {
},
altSpeed(){
return this.getStatus().altSpeed
},
series(){
return [
{
name: 'upload',
type: 'area',
data: this.$store.state.upload_data
},
{
name: 'download',
type: 'area',
data: this.$store.state.download_data
}
]
}
},
created() {
this.chartInterval = setInterval(async () => {
this.updateChart()
}, 2000)
this.$vuetify.theme.dark = this.getTheme()
},
beforeDestroy() {
clearInterval(this.chartInterval)
}
}
</script>

View file

@ -89,9 +89,10 @@
<v-flex xs12 sm12 md12>
<v-progress-linear
height="3"
rounded
color="cyan darken-1"
background-color="cyan lighten-3"
:value="(torrent.dloaded / torrent.size) * 100"
:value="torrent.progress"
></v-progress-linear>
</v-flex>
</v-layout>
@ -99,7 +100,6 @@
<span>{{ torrent.name }}</span>
</v-tooltip>
<v-divider></v-divider>
<vue-context ref="menu">
<torrentRightClickMenu :hash="torrent.hash" />
</vue-context>

View file

@ -6,6 +6,11 @@
:dark="dark"
>
<v-list dense rounded>
<v-list-item @click="showInfo" link>
<v-icon>info</v-icon>
<v-list-item-title class="ml-2" style="font-size:15px;">Show Info</v-list-item-title>
</v-list-item>
<v-divider/>
<v-list-item @click="resume" link>
<v-icon>play_arrow</v-icon>
<v-list-item-title class="ml-2" style="font-size:15px;">Resume</v-list-item-title>
@ -54,6 +59,10 @@ export default {
},
deleteWithFiles(){
qbit.deleteTorrents([this.hash], true)
},
showInfo(){
this.$store.commit('TOGGLE_MODAL', 'TorrentDetailModal')
this.$store.commit('SET_SELECTED_TORRENT_DETAIL', this.hash)
}
},
computed: {

79
src/helpers.js Normal file
View file

@ -0,0 +1,79 @@
/* eslint-disable no-unused-vars */
export function formatBytes(a, b) {
if (a == 0) return "0 Bytes";
const c = 1024;
const d = b || 2;
const e = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const f = Math.floor(Math.log(a) / Math.log(c));
return `${parseFloat((a / Math.pow(c, f)).toFixed(d))} ${e[f]}`;
}
export function getIconForFileType(type) {
let types = {
html: "mdi-language-html5",
js: "mdi-nodejs",
json: "mdi-json",
md: "mdi-markdown",
pdf: "mdi-file-pdf",
png: "mdi-file-image",
txt: "mdi-file-document-outline",
sub: "mdi-file-document-outline",
idx: "mdi-file-document-outline",
xls: "mdi-file-excel",
avi: "movie",
mp4: "movie",
mkv: "movie",
};
if (!types[type]) return "insert_drive_file";
return types[type];
}
export function treeify(paths) {
let result = [];
let level = { result };
paths.forEach((path) => {
path.name.split("/").reduce((r, name, i, a) => {
if (!r[name]) {
r[name] = { result: [] };
r.result.push(createFile(path, name, r[name].result));
}
return r[name];
}, level);
});
//parse folders
result = result.map((el) => parseFolder(el));
function parseFolder(el) {
if (el.children.length !== 0) {
let folder = createFolder(el.name, el.children);
folder.children = folder.children.map((el) => parseFolder(el));
return folder;
}
return el;
}
return result;
}
function createFile(data, name, children) {
return {
name: name,
progress: Math.round(data.progress * 100),
size: formatBytes(data.size),
icon: getIconForFileType(name.split(".").pop()),
children: children,
};
}
function createFolder(name, children) {
return {
name: name,
type: "directory",
children: children,
};
}

View file

@ -11,7 +11,16 @@ Vue.use(VueObserveVisibility)
import Toast from 'vue-toastification'
import 'vue-toastification/dist/index.css'
import vuetify from './plugins/vuetify'
Vue.use(Toast)
Vue.use(Toast, {
maxToasts: 5,
timeout: 2000
})
import PerfectScrollbar from 'vue2-perfect-scrollbar'
import 'vue2-perfect-scrollbar/dist/vue2-perfect-scrollbar.css'
Vue.use(PerfectScrollbar)
Vue.config.productionTip = false

View file

@ -8,6 +8,8 @@ export default class Status {
this.upspeed = this.formatBytes(data.up_info_speed, 1)
this.freeDiskSpace = this.formatBytes(data.free_space_on_disk)
this.altSpeed = data.use_alt_speed_limits
this.dlspeedRaw = Math.round(data.dl_info_speed / 1000)
this.upspeedRaw = Math.round(data.up_info_speed / 1000)
}
}

View file

@ -1,6 +1,5 @@
export default class Torrent {
constructor(data) {
this.id = data.id
this.name = data.name
this.size = this.formatBytes(data.size)
this.birth = new Date(data.added_on * 1000).toLocaleString()
@ -20,6 +19,9 @@ export default class Torrent {
// available seeds
this.available_seeds = data.num_complete
this.available_peers = data.num_incomplete
this.savePath = data.save_path
this.progress = Math.round(data.downloaded / data.size * 100)
this.ratio = Math.round(data.ratio * 100)
}
formatState(state) {

View file

@ -29,7 +29,8 @@ export default new Vuetify({
torrent: '#fff',
torrent_selected: colors.grey.lighten2,
background: colors.grey.lighten4,
search: colors.grey.darken1
search: colors.grey.darken1,
torrentmodaltext: colors.grey.darken4
},
dark: {
primary: "#35495e",
@ -44,7 +45,8 @@ export default new Vuetify({
torrent: colors.grey.darken3,
torrent_selected: colors.grey,
background: colors.grey.darken4,
search: colors.grey.darken3
search: colors.grey.darken3,
torrentmodaltext: colors.grey.lighten4
},
},
},

View file

@ -33,9 +33,11 @@ export default new Vuex.Store({
modals: {
addmodal: false,
deletemodal: false,
settingsmodal: false
settingsmodal: false,
torrentdetailmodal: false
},
settings : {}
settings : {},
selectedDetailTorrent: null
},
getters: {
containsTorrent: state => hash =>
@ -43,7 +45,8 @@ export default new Vuex.Store({
getTheme: state => () => state.darkTheme,
getModalState: state => name => state.modals[name.toLowerCase()],
getSettings: state => () => state.settings,
getStatus: state => () => state.status
getStatus: state => () => state.status,
getTorrent: state => hash => state.torrents.filter(el => el.hash === hash)[0]
},
mutations: {
@ -92,40 +95,17 @@ export default new Vuex.Store({
state.status = new Status(data.server_state)
// graph
state.download_data.splice(0, 1)
if (state.status.dlspeed.indexOf('KB' > -1)) {
state.download_data.push(
state.status.dlspeed.substring(
0,
state.status.dlspeed.indexOf(' ')
) / 1000
)
} else {
state.download_data.push(
state.status.dlspeed(0, state.status.dlspeed.indexOf(' '))
)
}
state.download_data.push(state.status.dlspeedRaw)
state.upload_data.splice(0, 1)
if (state.status.upspeed.indexOf('KB' > -1)) {
state.upload_data.push(
state.status.upspeed.substring(
0,
state.status.upspeed.indexOf(' ')
) / 1000
)
} else {
state.upload_data.push(
state.status.upspeed.substring(
0,
state.status.upspeed.indexOf(' ')
)
)
}
state.upload_data.push(state.status.upspeedRaw)
},
SET_SETTINGS: async state => {
const {data} = await qbit.getAppPreferences()
state.settings.savePath = data.save_path;
},
SET_SELECTED_TORRENT_DETAIL: (state, hash) => {
state.selectedDetailTorrent = hash
}
},
actions: {

View file

@ -1,7 +1,7 @@
<template>
<div style="height: 89vh" color="background" @click.self="resetSelected">
<div color="background" @click.self="resetSelected">
<h1 style="font-size: 1.1em !important" class="subtitle-1 grey--text">Dashboard</h1>
<v-container color="background" class="my-4" @click.self="resetSelected">
<v-container color="background" class="my-4 pa-0" @click.self="resetSelected">
<!-- justify-center here in layout to center!! -->
<v-flex xs12 sm6 md3 @click.self="resetSelected">
<v-text-field