Videos.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  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. const { openModal } = useModalsStore();
  217. const editOne = song => {
  218. openModal({
  219. modal: "editSong",
  220. props: { song }
  221. });
  222. };
  223. const editMany = selectedRows => {
  224. if (selectedRows.length === 1) editOne(selectedRows[0]);
  225. else {
  226. const songs = selectedRows.map(row => ({
  227. youtubeId: row.youtubeId
  228. }));
  229. openModal({ modal: "editSong", props: { songs } });
  230. }
  231. };
  232. const importAlbum = selectedRows => {
  233. const mediaSources = selectedRows.map(({ youtubeId }) => youtubeId);
  234. console.log(77988, mediaSources);
  235. socket.dispatch("songs.getSongsFromMediaSources", mediaSources, res => {
  236. if (res.status === "success") {
  237. openModal({
  238. modal: "importAlbum",
  239. props: { songs: res.data.songs }
  240. });
  241. } else new Toast("Could not get songs.");
  242. });
  243. };
  244. const bulkEditPlaylist = selectedRows => {
  245. openModal({
  246. modal: "bulkEditPlaylist",
  247. props: {
  248. mediaSources: selectedRows.map(row => row.youtubeId)
  249. }
  250. });
  251. };
  252. const removeVideos = videoIds => {
  253. let id;
  254. let title;
  255. socket.dispatch("youtube.removeVideos", videoIds, {
  256. cb: () => {},
  257. onProgress: res => {
  258. if (res.status === "started") {
  259. id = res.id;
  260. title = res.title;
  261. }
  262. if (id)
  263. setJob({
  264. id,
  265. name: title,
  266. ...res
  267. });
  268. }
  269. });
  270. };
  271. </script>
  272. <template>
  273. <div class="admin-tab container">
  274. <page-metadata title="Admin | YouTube | Videos" />
  275. <div class="card tab-info">
  276. <div class="info-row">
  277. <h1>YouTube Videos</h1>
  278. <p>Manage YouTube video cache</p>
  279. </div>
  280. <div class="button-row">
  281. <run-job-dropdown :jobs="jobs" />
  282. </div>
  283. </div>
  284. <advanced-table
  285. :column-default="columnDefault"
  286. :columns="columns"
  287. :filters="filters"
  288. :events="events"
  289. data-action="youtube.getVideos"
  290. name="admin-youtube-videos"
  291. :max-width="1140"
  292. :bulk-actions="bulkActions"
  293. >
  294. <template #column-options="slotProps">
  295. <div class="row-options">
  296. <button
  297. class="button is-primary icon-with-button material-icons"
  298. @click="
  299. openModal({
  300. modal: 'viewYoutubeVideo',
  301. props: {
  302. videoId: slotProps.item.youtubeId
  303. }
  304. })
  305. "
  306. :disabled="slotProps.item.removed"
  307. content="View Video"
  308. v-tippy
  309. >
  310. open_in_full
  311. </button>
  312. <button
  313. v-if="
  314. hasPermission('songs.create') ||
  315. hasPermission('songs.update')
  316. "
  317. class="button is-primary icon-with-button material-icons"
  318. @click="editOne(slotProps.item)"
  319. :disabled="slotProps.item.removed"
  320. :content="
  321. !!slotProps.item.songId
  322. ? 'Edit Song'
  323. : 'Create song from video'
  324. "
  325. v-tippy
  326. >
  327. music_note
  328. </button>
  329. <button
  330. v-if="hasPermission('youtube.removeVideos')"
  331. class="button is-danger icon-with-button material-icons"
  332. @click.prevent="
  333. openModal({
  334. modal: 'confirm',
  335. props: {
  336. message:
  337. 'Removing this video will remove it from all playlists and cause a ratings recalculation.',
  338. onCompleted: () =>
  339. removeVideos(slotProps.item._id)
  340. }
  341. })
  342. "
  343. :disabled="slotProps.item.removed"
  344. content="Delete Video"
  345. v-tippy
  346. >
  347. delete_forever
  348. </button>
  349. </div>
  350. </template>
  351. <template #column-thumbnailImage="slotProps">
  352. <song-thumbnail class="song-thumbnail" :song="slotProps.item" />
  353. </template>
  354. <template #column-youtubeId="slotProps">
  355. <a
  356. :href="
  357. 'https://www.youtube.com/watch?v=' +
  358. `${slotProps.item.youtubeId}`
  359. "
  360. target="_blank"
  361. >
  362. {{ slotProps.item.youtubeId }}
  363. </a>
  364. </template>
  365. <template #column-_id="slotProps">
  366. <span :title="slotProps.item._id">{{
  367. slotProps.item._id
  368. }}</span>
  369. </template>
  370. <template #column-title="slotProps">
  371. <span :title="slotProps.item.title">{{
  372. slotProps.item.title
  373. }}</span>
  374. </template>
  375. <template #column-author="slotProps">
  376. <span :title="slotProps.item.author">{{
  377. slotProps.item.author
  378. }}</span>
  379. </template>
  380. <template #column-duration="slotProps">
  381. <span :title="`${slotProps.item.duration}`">{{
  382. slotProps.item.duration
  383. }}</span>
  384. </template>
  385. <template #column-createdAt="slotProps">
  386. <span :title="new Date(slotProps.item.createdAt).toString()">{{
  387. utils.getDateFormatted(slotProps.item.createdAt)
  388. }}</span>
  389. </template>
  390. <template #column-songId="slotProps">
  391. <span :title="slotProps.item.songId">{{
  392. slotProps.item.songId
  393. }}</span>
  394. </template>
  395. <template #column-uploadedAt="slotProps">
  396. <span :title="new Date(slotProps.item.uploadedAt).toString()">{{
  397. utils.getDateFormatted(slotProps.item.uploadedAt)
  398. }}</span>
  399. </template>
  400. <template #bulk-actions="slotProps">
  401. <div class="bulk-actions">
  402. <i
  403. v-if="
  404. hasPermission('songs.create') ||
  405. hasPermission('songs.update')
  406. "
  407. class="material-icons create-songs-icon"
  408. @click.prevent="editMany(slotProps.item)"
  409. content="Create/edit songs from videos"
  410. v-tippy
  411. tabindex="0"
  412. >
  413. music_note
  414. </i>
  415. <i
  416. v-if="
  417. hasPermission('songs.create') ||
  418. hasPermission('songs.update')
  419. "
  420. class="material-icons import-album-icon"
  421. @click.prevent="importAlbum(slotProps.item)"
  422. content="Import album from videos"
  423. v-tippy
  424. tabindex="0"
  425. >
  426. album
  427. </i>
  428. <i
  429. v-if="hasPermission('playlists.songs.add')"
  430. class="material-icons playlist-bulk-edit-icon"
  431. @click.prevent="bulkEditPlaylist(slotProps.item)"
  432. content="Add To Playlist"
  433. v-tippy
  434. tabindex="0"
  435. >
  436. playlist_add
  437. </i>
  438. <i
  439. v-if="hasPermission('youtube.removeVideos')"
  440. class="material-icons delete-icon"
  441. @click.prevent="
  442. openModal({
  443. modal: 'confirm',
  444. props: {
  445. message:
  446. 'Removing these videos will remove them from all playlists and cause a ratings recalculation.',
  447. onCompleted: () =>
  448. removeVideos(
  449. slotProps.item.map(
  450. video => video._id
  451. )
  452. )
  453. }
  454. })
  455. "
  456. content="Delete Videos"
  457. v-tippy
  458. tabindex="0"
  459. >
  460. delete_forever
  461. </i>
  462. </div>
  463. </template>
  464. </advanced-table>
  465. </div>
  466. </template>
  467. <style lang="less" scoped>
  468. :deep(.song-thumbnail) {
  469. width: 50px;
  470. height: 50px;
  471. min-width: 50px;
  472. min-height: 50px;
  473. margin: 0 auto;
  474. }
  475. </style>