Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for youtube playlist imports in cvs #5498

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 116 additions & 23 deletions src/renderer/components/data-settings/data-settings.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineComponent } from 'vue'
import path from 'path'
import FtSettingsSection from '../ft-settings-section/ft-settings-section.vue'
import { mapActions, mapMutations } from 'vuex'
import FtButton from '../ft-button/ft-button.vue'
Expand All @@ -9,6 +10,7 @@ import { MAIN_PROFILE_ID } from '../../../constants'

import { calculateColorLuminance, getRandomColor } from '../../helpers/colors'
import {
replaceLast,
copyToClipboard,
deepCopy,
escapeHTML,
Expand All @@ -18,9 +20,11 @@ import {
showSaveDialog,
showToast,
writeFileFromDialog,
parseCsv,
} from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannel } from '../../helpers/api/local'
import { getVideoInfo } from '../../helpers/api/preference'

export default defineComponent({
name: 'DataSettings',
Expand Down Expand Up @@ -212,9 +216,6 @@ export default defineComponent({
},

importCsvYouTubeSubscriptions: async function(textDecode) { // first row = header, last row = empty
const youtubeSubscriptions = textDecode.split('\n').filter(sub => {
return sub !== ''
})
const subscriptions = []
const errorList = []

Expand All @@ -224,17 +225,7 @@ export default defineComponent({
this.setProgressBarPercentage(0)
let count = 0

const splitCSVRegex = /(?:,|\n|^)("(?:(?:"")|[^"])*"|[^\n",]*|(?:\n|$))/g

const ytsubs = youtubeSubscriptions.slice(1).map(yt => {
return [...yt.matchAll(splitCSVRegex)].map(s => {
let newVal = s[1]
if (newVal.startsWith('"')) {
newVal = newVal.substring(1, newVal.length - 2).replaceAll('""', '"')
}
return newVal
})
}).filter(channel => {
const ytsubs = parseCsv(textDecode).slice(1).filter(channel => {
return channel.length > 0
})
new Promise((resolve) => {
Expand Down Expand Up @@ -863,11 +854,11 @@ export default defineComponent({

importPlaylists: async function () {
const options = {
properties: ['openFile'],
properties: ['openFile', 'multiSelections'],
filters: [
{
name: this.$t('Settings.Data Settings.Playlist File'),
extensions: ['db']
extensions: ['db', 'csv']
}
]
}
Expand All @@ -876,14 +867,29 @@ export default defineComponent({
if (response.canceled || response.filePaths?.length === 0) {
return
}
let data
try {
data = await readFileFromDialog(response)
} catch (err) {
const message = this.$t('Settings.Data Settings.Unable to read file')
showToast(`${message}: ${err}`)
return

for (let i = 0; i < response.filePaths.length; i++) {
const filePath = response.filePaths[i]
let textDecode
try {
textDecode = await readFileFromDialog(response, i)
} catch (err) {
const fileName = path.basename(filePath)
const message = this.$t('Settings.Data Settings.Invalid subscriptions file')
showToast(`${message} ${fileName}: ${err}`)
return
}

if (filePath.endsWith('.db')) {
this.importFreeTubePlaylists(textDecode)
} else if (filePath.endsWith('.csv')) {
await this.importCsvYouTubePlaylists(filePath, textDecode)
}
}
},

importFreeTubePlaylists: async function (textDecode) {
let data = textDecode
let playlists = null

// for the sake of backwards compatibility,
Expand Down Expand Up @@ -1019,6 +1025,93 @@ export default defineComponent({
showToast(this.$t('Settings.Data Settings.All playlists has been successfully imported'))
},

importCsvYouTubePlaylists: async function (filePath, textDecode) {
const fileName = path.basename(filePath)
const playlistName = (() => {
if (fileName.endsWith(' - video.csv')) {
return replaceLast(fileName, ' - video.csv', '')
} else if (fileName.endsWith('.csv')) {
return replaceLast(fileName, '.csv', '')
} else {
return fileName
}
})()

const videosData = parseCsv(textDecode).slice(1).filter(channel => {
return channel.length > 0
})

const message = this.$t('Settings.Data Settings.This might take a while, please wait')
showToast(message + ' ' + playlistName, 5000)
this.updateShowProgressBar(true)
this.setProgressBarPercentage(5)

const videos = await (async () => {
const result = []
for (let i = 0; i < videosData.length; i++) {
const videoData = videosData[i]
const videoId = videoData[0]
try {
const videoInfo = (await getVideoInfo(videoId))
const videoObj = {
author: videoInfo.author,
authorId: videoInfo.authorId,
lengthSeconds: videoInfo.lengthSeconds,
title: videoInfo.title,
videoId: videoId,
}
result.push(videoObj)
} catch (err) {
const errorMessage = this.$t('Settings.Data Settings.Unable to load video')
showToast(`${errorMessage} ID: ${videoId}: ${err}`, 10000, () => {
copyToClipboard(err)
})
}

const percentage = Math.floor((i / videosData.length) * 100)
this.setProgressBarPercentage(percentage)
}
return result
})()

const playlistObject = {
description: '',
playlistName: playlistName,
videos: videos
}

const existingPlaylist = this.allPlaylists.find((playlist) => {
return playlist.playlistName === playlistObject.playlistName
})

if (existingPlaylist !== undefined) {
playlistObject.videos.forEach((video) => {
const videoExists = existingPlaylist.videos.some((x) => {
// Disllow duplicate (by videoId) videos to be added
return x.videoId === video.videoId
})

if (!videoExists) {
// Keep original `timeAdded` value
const payload = {
_id: existingPlaylist._id,
videoData: video,
}

this.addVideo(payload)
}
})
// Update playlist's `lastUpdatedAt`
this.updatePlaylist({ _id: existingPlaylist._id })
} else {
this.addPlaylist(playlistObject)
}

this.updateShowProgressBar(false)
const endMessage = this.$t('Settings.Data Settings.Playlists has been successfully exported')
showToast(`${endMessage}: ${playlistName}`, 5000)
},

exportPlaylists: async function () {
const dateStr = getTodayDateStrLocalTimezone()
const exportFileName = 'freetube-playlists-' + dateStr + '.db'
Expand Down
150 changes: 150 additions & 0 deletions src/renderer/helpers/api/preference.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import store from '../../store/index'
import i18n from '../../i18n/index'
import { invidiousGetVideoInformation } from './invidious'
import { getLocalVideoInfo, parseLocalTextRuns } from './local'
import { copyToClipboard, showToast } from '../../helpers/utils'

async function runOnPreferredApi(localFunc, invidiousFunc) {
return new Promise((resolve, reject) => {
const backendPreference = store.getters.getBackendPreference
const backendFallback = store.getters.getBackendFallback

const useInvidious = !process.env.SUPPORTS_LOCAL_API || backendPreference === 'invidious'
const mainFunction = useInvidious ? invidiousFunc : localFunc
const fallbackFunction = useInvidious ? localFunc : invidiousFunc

mainFunction()
.then(result => {
const api = useInvidious ? 'invidious' : 'local'
resolve({ data: result, api: api })
})
.catch(async err => {
console.error('Unable to get data with preferred API')
console.error(err)
const errorMessage = useInvidious
? i18n.t('Invidious API Error (Click to copy)')
: i18n.t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})

if (backendFallback && ((!useInvidious) || (useInvidious && process.env.SUPPORTS_LOCAL_API))) {
try {
const fallbackResult = await fallbackFunction()
const fallbackApi = useInvidious ? 'local' : 'invidious'
resolve({ data: fallbackResult, api: fallbackApi })
} catch (err) {
reject(err)
}
} else {
reject(err)
}
})
})
}

export async function getVideoInfo(videoId) {
return new Promise((resolve, reject) => {
const currentLocale = i18n.locale.replace('_', '-')

const getInvidious = ((videoId) => {
return invidiousGetVideoInformation(videoId)
}).bind(null, videoId)
const getLocal = ((videoId) => {
return getLocalVideoInfo(videoId)
}).bind(null, videoId)

// getVideoInfo supported data
let videoData = {
videoId: '',
title: '',
author: '',
authorId: '',
published: 0,
description: '',
viewCount: '',
isFamilyFriendly: false,
liveNow: false,
isUpcoming: false,
isLiveContent: false,
isPostLiveDvr: false,
lengthSeconds: 0,
thumbnail: '',
likeCount: 0,
dislikeCount: 0,
type: 'video',
}
runOnPreferredApi(getLocal, getInvidious)
.then((obj) => {
const { data, api } = obj

if (api === 'local') {
videoData.videoId = videoId
videoData.title = data.primary_info?.title.text ?? data.basic_info.title
videoData.author = data.basic_info.author
videoData.authorId = data.basic_info.channel_id
videoData.published = data.videoPublished = new Date(data.page[0].microformat?.publish_date).getTime()
videoData.description = ''
if (data.secondary_info?.description.runs) {
try {
videoData.description = parseLocalTextRuns(data.secondary_info.description.runs)
} catch (error) {
console.error('Failed to extract the localised description, falling back to the standard one.', error, JSON.stringify(data.secondary_info.description.runs))
videoData.description = data.basic_info.short_description
}
} else {
videoData.description = data.basic_info.short_description
}
videoData.viewCount = data.basic_info.view_count
videoData.isFamilyFriendly = data.basic_info.is_family_safe
videoData.liveNow = !!data.basic_info.is_live
videoData.isUpcoming = !!data.basic_info.is_upcoming
videoData.isLiveContent = !!data.basic_info.is_live_content
videoData.isPostLiveDvr = !!data.basic_info.is_post_live_dvr
videoData.isCommentsEnabled = data.comments_entry_point_header != null
videoData.lengthSeconds = 0
if (videoData.isUpcoming) {
let upcomingTimestamp = data.basic_info.start_timestamp
if (upcomingTimestamp) {
const timestampOptions = {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
const now = new Date()
if (now.getFullYear() < upcomingTimestamp.getFullYear()) {
Object.defineProperty(timestampOptions, 'year', {
value: 'numeric'
})
}
upcomingTimestamp = Intl.DateTimeFormat(currentLocale, timestampOptions).format(upcomingTimestamp)
let upcomingTimeLeft = upcomingTimestamp - now
upcomingTimeLeft = (upcomingTimeLeft / 1000)
upcomingTimeLeft = Math.floor(upcomingTimeLeft)
videoData.lengthSeconds = upcomingTimeLeft
}
} else {
videoData.lengthSeconds = data.basic_info.duration
}
videoData.thumbnail = data.basic_info?.thumbnail?.length > 0 ? data.basic_info.thumbnail[0].url : undefined
videoData.likeCount = isNaN(data.basic_info.like_count) ? 0 : data.basic_info.like_count
// YouTube doesn't return dislikes anymore
videoData.dislikeCount = 0
} else if (api === 'invidious') {
videoData = data
videoData.isLiveContent = videoData.liveNow || videoData.isUpcoming || videoData.isPostLiveDvr
videoData.thumbnail = data.videoThumbnails[0].url
}

if (api === 'invidious' && videoData.title.length === 0) {
reject(i18n.t('Settings.Data Settings.Unable to load video'))
}

resolve(videoData)
})
.catch((err) => {
reject(err)
})
})
}
Loading
Loading