Videos.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. <script setup lang="ts">
  2. import { defineAsyncComponent, ref } from "vue";
  3. import Toast from "toasters";
  4. import { useWebsocketsStore } from "@/stores/websockets";
  5. import { useLongJobsStore } from "@/stores/longJobs";
  6. import { useModalsStore } from "@/stores/modals";
  7. import { useUserAuthStore } from "@/stores/userAuth";
  8. import {
  9. TableColumn,
  10. TableFilter,
  11. TableEvents,
  12. TableBulkActions
  13. } from "@/types/advancedTable";
  14. import utils from "@/utils";
  15. const AdvancedTable = defineAsyncComponent(
  16. () => import("@/components/AdvancedTable.vue")
  17. );
  18. const RunJobDropdown = defineAsyncComponent(
  19. () => import("@/components/RunJobDropdown.vue")
  20. );
  21. const SongThumbnail = defineAsyncComponent(
  22. () => import("@/components/SongThumbnail.vue")
  23. );
  24. const { setJob } = useLongJobsStore();
  25. const { socket } = useWebsocketsStore();
  26. const userAuthStore = useUserAuthStore();
  27. const { hasPermission } = userAuthStore;
  28. const columnDefault = ref<TableColumn>({
  29. sortable: true,
  30. hidable: true,
  31. defaultVisibility: "shown",
  32. draggable: true,
  33. resizable: true,
  34. minWidth: 200,
  35. maxWidth: 600
  36. });
  37. const columns = ref<TableColumn[]>([
  38. {
  39. name: "options",
  40. displayName: "Options",
  41. properties: ["_id", "youtubeId", "songId"],
  42. sortable: false,
  43. hidable: false,
  44. resizable: false,
  45. minWidth:
  46. (hasPermission("songs.create") || hasPermission("songs.update")) &&
  47. hasPermission("youtube.removeVideos")
  48. ? 129
  49. : 85,
  50. defaultWidth:
  51. (hasPermission("songs.create") || hasPermission("songs.update")) &&
  52. hasPermission("youtube.removeVideos")
  53. ? 129
  54. : 85
  55. },
  56. {
  57. name: "thumbnailImage",
  58. displayName: "Thumb",
  59. properties: ["youtubeId"],
  60. sortable: false,
  61. minWidth: 75,
  62. defaultWidth: 75,
  63. maxWidth: 75,
  64. resizable: false
  65. },
  66. {
  67. name: "youtubeId",
  68. displayName: "YouTube ID",
  69. properties: ["youtubeId"],
  70. sortProperty: "youtubeId",
  71. minWidth: 120,
  72. defaultWidth: 120
  73. },
  74. {
  75. name: "_id",
  76. displayName: "Video ID",
  77. properties: ["_id"],
  78. sortProperty: "_id",
  79. minWidth: 215,
  80. defaultWidth: 215
  81. },
  82. {
  83. name: "title",
  84. displayName: "Title",
  85. properties: ["title"],
  86. sortProperty: "title"
  87. },
  88. {
  89. name: "author",
  90. displayName: "Author",
  91. properties: ["author"],
  92. sortProperty: "author"
  93. },
  94. {
  95. name: "duration",
  96. displayName: "Duration",
  97. properties: ["duration"],
  98. sortProperty: "duration",
  99. defaultWidth: 200
  100. },
  101. {
  102. name: "createdAt",
  103. displayName: "Created At",
  104. properties: ["createdAt"],
  105. sortProperty: "createdAt",
  106. defaultWidth: 200,
  107. defaultVisibility: "hidden"
  108. },
  109. {
  110. name: "songId",
  111. displayName: "Song ID",
  112. properties: ["songId"],
  113. sortProperty: "songId",
  114. defaultWidth: 220,
  115. defaultVisibility: "hidden"
  116. },
  117. {
  118. name: "uploadedAt",
  119. displayName: "Uploaded At",
  120. properties: ["uploadedAt"],
  121. sortProperty: "uploadedAt",
  122. defaultWidth: 200,
  123. defaultVisibility: "hidden"
  124. }
  125. ]);
  126. const filters = ref<TableFilter[]>([
  127. {
  128. name: "_id",
  129. displayName: "Video ID",
  130. property: "_id",
  131. filterTypes: ["exact"],
  132. defaultFilterType: "exact"
  133. },
  134. {
  135. name: "youtubeId",
  136. displayName: "YouTube ID",
  137. property: "youtubeId",
  138. filterTypes: ["contains", "exact", "regex"],
  139. defaultFilterType: "contains"
  140. },
  141. {
  142. name: "title",
  143. displayName: "Title",
  144. property: "title",
  145. filterTypes: ["contains", "exact", "regex"],
  146. defaultFilterType: "contains"
  147. },
  148. {
  149. name: "author",
  150. displayName: "Author",
  151. property: "author",
  152. filterTypes: ["contains", "exact", "regex"],
  153. defaultFilterType: "contains"
  154. },
  155. {
  156. name: "duration",
  157. displayName: "Duration",
  158. property: "duration",
  159. filterTypes: [
  160. "numberLesserEqual",
  161. "numberLesser",
  162. "numberGreater",
  163. "numberGreaterEqual",
  164. "numberEquals"
  165. ],
  166. defaultFilterType: "numberLesser"
  167. },
  168. {
  169. name: "createdAt",
  170. displayName: "Created At",
  171. property: "createdAt",
  172. filterTypes: ["datetimeBefore", "datetimeAfter"],
  173. defaultFilterType: "datetimeBefore"
  174. },
  175. {
  176. name: "importJob",
  177. displayName: "Import Job",
  178. property: "importJob",
  179. filterTypes: ["special"],
  180. defaultFilterType: "special"
  181. },
  182. {
  183. name: "songId",
  184. displayName: "Song ID",
  185. property: "songId",
  186. filterTypes: ["contains", "exact", "regex"],
  187. defaultFilterType: "contains"
  188. },
  189. {
  190. name: "uploadedAt",
  191. displayName: "Uploaded At",
  192. property: "uploadedAt",
  193. filterTypes: ["datetimeBefore", "datetimeAfter"],
  194. defaultFilterType: "datetimeBefore"
  195. }
  196. ]);
  197. const events = ref<TableEvents>({
  198. adminRoom: "youtubeVideos",
  199. updated: {
  200. event: "admin.youtubeVideo.updated",
  201. id: "youtubeVideo._id",
  202. item: "youtubeVideo"
  203. },
  204. removed: {
  205. event: "admin.youtubeVideo.removed",
  206. id: "videoId"
  207. }
  208. });
  209. const bulkActions = ref<TableBulkActions>({ width: 200 });
  210. const jobs = ref([]);
  211. if (hasPermission("media.recalculateAllRatings"))
  212. jobs.value.push({
  213. name: "Recalculate all ratings",
  214. socket: "media.recalculateAllRatings"
  215. });
  216. jobs.value.push({
  217. name: "Get missing YouTube video's",
  218. socket: "youtube.getMissingVideos"
  219. });
  220. jobs.value.push({
  221. name: "Update V1 video's to V2",
  222. socket: "youtube.updateVideosV1ToV2"
  223. });
  224. const { openModal } = useModalsStore();
  225. const rowToSong = row => ({
  226. mediaSource: `youtube:${row.youtubeId}`
  227. });
  228. const editOne = row => {
  229. openModal({
  230. modal: "editSong",
  231. props: { song: rowToSong(row) }
  232. });
  233. };
  234. const editMany = selectedRows => {
  235. if (selectedRows.length === 1) editOne(rowToSong(selectedRows[0]));
  236. else {
  237. const songs = selectedRows.map(rowToSong);
  238. openModal({ modal: "editSong", props: { songs } });
  239. }
  240. };
  241. const importAlbum = selectedRows => {
  242. const mediaSources = selectedRows.map(
  243. ({ youtubeId }) => `youtube:${youtubeId}`
  244. );
  245. socket.dispatch("songs.getSongsFromMediaSources", mediaSources, res => {
  246. if (res.status === "success") {
  247. openModal({
  248. modal: "importAlbum",
  249. props: { songs: res.data.songs }
  250. });
  251. } else new Toast("Could not get songs.");
  252. });
  253. };
  254. const bulkEditPlaylist = selectedRows => {
  255. openModal({
  256. modal: "bulkEditPlaylist",
  257. props: {
  258. mediaSources: selectedRows.map(row => `youtube:${row.youtubeId}`)
  259. }
  260. });
  261. };
  262. const removeVideos = videoIds => {
  263. let id;
  264. let title;
  265. socket.dispatch("youtube.removeVideos", videoIds, {
  266. cb: () => {},
  267. onProgress: res => {
  268. if (res.status === "started") {
  269. id = res.id;
  270. title = res.title;
  271. }
  272. if (id)
  273. setJob({
  274. id,
  275. name: title,
  276. ...res
  277. });
  278. }
  279. });
  280. };
  281. </script>
  282. <template>
  283. <div class="admin-tab container">
  284. <page-metadata title="Admin | YouTube | Videos" />
  285. <div class="card tab-info">
  286. <div class="info-row">
  287. <h1>YouTube Videos</h1>
  288. <p>Manage YouTube video cache</p>
  289. </div>
  290. <div class="button-row">
  291. <run-job-dropdown :jobs="jobs" />
  292. </div>
  293. </div>
  294. <advanced-table
  295. :column-default="columnDefault"
  296. :columns="columns"
  297. :filters="filters"
  298. :events="events"
  299. data-action="youtube.getVideos"
  300. name="admin-youtube-videos"
  301. :max-width="1140"
  302. :bulk-actions="bulkActions"
  303. >
  304. <template #column-options="slotProps">
  305. <div class="row-options">
  306. <button
  307. class="button is-primary icon-with-button material-icons"
  308. @click="
  309. openModal({
  310. modal: 'viewMedia',
  311. props: {
  312. mediaSource: `youtube:${slotProps.item.youtubeId}`
  313. }
  314. })
  315. "
  316. :disabled="slotProps.item.removed"
  317. content="View Video"
  318. v-tippy
  319. >
  320. open_in_full
  321. </button>
  322. <button
  323. v-if="
  324. hasPermission('songs.create') ||
  325. hasPermission('songs.update')
  326. "
  327. class="button is-primary icon-with-button material-icons"
  328. @click="editOne(slotProps.item)"
  329. :disabled="slotProps.item.removed"
  330. :content="
  331. !!slotProps.item.songId
  332. ? 'Edit Song'
  333. : 'Create song from video'
  334. "
  335. v-tippy
  336. >
  337. music_note
  338. </button>
  339. <button
  340. v-if="hasPermission('youtube.removeVideos')"
  341. class="button is-danger icon-with-button material-icons"
  342. @click.prevent="
  343. openModal({
  344. modal: 'confirm',
  345. props: {
  346. message:
  347. 'Removing this video will remove it from all playlists and cause a ratings recalculation.',
  348. onCompleted: () =>
  349. removeVideos(slotProps.item._id)
  350. }
  351. })
  352. "
  353. :disabled="slotProps.item.removed"
  354. content="Delete Video"
  355. v-tippy
  356. >
  357. delete_forever
  358. </button>
  359. </div>
  360. </template>
  361. <template #column-thumbnailImage="slotProps">
  362. <song-thumbnail class="song-thumbnail" :song="slotProps.item" />
  363. </template>
  364. <template #column-youtubeId="slotProps">
  365. <a
  366. :href="
  367. 'https://www.youtube.com/watch?v=' +
  368. `${slotProps.item.youtubeId}`
  369. "
  370. target="_blank"
  371. >
  372. {{ slotProps.item.youtubeId }}
  373. </a>
  374. </template>
  375. <template #column-_id="slotProps">
  376. <span :title="slotProps.item._id">{{
  377. slotProps.item._id
  378. }}</span>
  379. </template>
  380. <template #column-title="slotProps">
  381. <span :title="slotProps.item.title">{{
  382. slotProps.item.title
  383. }}</span>
  384. </template>
  385. <template #column-author="slotProps">
  386. <span :title="slotProps.item.author">{{
  387. slotProps.item.author
  388. }}</span>
  389. </template>
  390. <template #column-duration="slotProps">
  391. <span :title="`${slotProps.item.duration}`">{{
  392. slotProps.item.duration
  393. }}</span>
  394. </template>
  395. <template #column-createdAt="slotProps">
  396. <span :title="new Date(slotProps.item.createdAt).toString()">{{
  397. utils.getDateFormatted(slotProps.item.createdAt)
  398. }}</span>
  399. </template>
  400. <template #column-songId="slotProps">
  401. <span :title="slotProps.item.songId">{{
  402. slotProps.item.songId
  403. }}</span>
  404. </template>
  405. <template #column-uploadedAt="slotProps">
  406. <span :title="new Date(slotProps.item.uploadedAt).toString()">{{
  407. utils.getDateFormatted(slotProps.item.uploadedAt)
  408. }}</span>
  409. </template>
  410. <template #bulk-actions="slotProps">
  411. <div class="bulk-actions">
  412. <i
  413. v-if="
  414. hasPermission('songs.create') ||
  415. hasPermission('songs.update')
  416. "
  417. class="material-icons create-songs-icon"
  418. @click.prevent="editMany(slotProps.item)"
  419. content="Create/edit songs from videos"
  420. v-tippy
  421. tabindex="0"
  422. >
  423. music_note
  424. </i>
  425. <i
  426. v-if="
  427. hasPermission('songs.create') ||
  428. hasPermission('songs.update')
  429. "
  430. class="material-icons import-album-icon"
  431. @click.prevent="importAlbum(slotProps.item)"
  432. content="Import album from videos"
  433. v-tippy
  434. tabindex="0"
  435. >
  436. album
  437. </i>
  438. <i
  439. v-if="hasPermission('playlists.songs.add')"
  440. class="material-icons playlist-bulk-edit-icon"
  441. @click.prevent="bulkEditPlaylist(slotProps.item)"
  442. content="Add To Playlist"
  443. v-tippy
  444. tabindex="0"
  445. >
  446. playlist_add
  447. </i>
  448. <i
  449. v-if="hasPermission('youtube.removeVideos')"
  450. class="material-icons delete-icon"
  451. @click.prevent="
  452. openModal({
  453. modal: 'confirm',
  454. props: {
  455. message:
  456. 'Removing these videos will remove them from all playlists and cause a ratings recalculation.',
  457. onCompleted: () =>
  458. removeVideos(
  459. slotProps.item.map(
  460. video => video._id
  461. )
  462. )
  463. }
  464. })
  465. "
  466. content="Delete Videos"
  467. v-tippy
  468. tabindex="0"
  469. >
  470. delete_forever
  471. </i>
  472. </div>
  473. </template>
  474. </advanced-table>
  475. </div>
  476. </template>
  477. <style lang="less" scoped>
  478. :deep(.song-thumbnail) {
  479. width: 50px;
  480. height: 50px;
  481. min-width: 50px;
  482. min-height: 50px;
  483. margin: 0 auto;
  484. }
  485. </style>