Local API: Add support for hashtag pages (#3483)
* Add support for hashtag pages * Apply suggestions from code review Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * allow searching hashtags Co-Authored-By: absidue <48293849+absidue@users.noreply.github.com> * Only use one card Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * remove hashtag alias search navigation --------- Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
This commit is contained in:
co-authored by
absidue
parent
e32417b9cb
commit
a4d45b5fa8
+7
-8
@@ -64,7 +64,8 @@ export default defineComponent({
|
||||
return this.$store.getters.getCheckForBlogPosts
|
||||
},
|
||||
windowTitle: function () {
|
||||
if (this.$route.meta.title !== 'Channel' && this.$route.meta.title !== 'Watch') {
|
||||
const routeTitle = this.$route.meta.title
|
||||
if (routeTitle !== 'Channel' && routeTitle !== 'Watch' && routeTitle !== 'Hashtag') {
|
||||
let title =
|
||||
this.$route.meta.path === '/home'
|
||||
? packageDetails.productName
|
||||
@@ -407,13 +408,11 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
case 'hashtag': {
|
||||
// TODO: Implement a hashtag related view
|
||||
let message = 'Hashtags have not yet been implemented, try again later'
|
||||
if (this.$te(message) && this.$t(message) !== '') {
|
||||
message = this.$t(message)
|
||||
}
|
||||
|
||||
showToast(message)
|
||||
const { hashtag } = result
|
||||
openInternalPath({
|
||||
path: `/hashtag/${encodeURIComponent(hashtag)}`,
|
||||
doCreateNewWindow
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -176,11 +176,8 @@ export default defineComponent({
|
||||
case 'playlist':
|
||||
case 'search':
|
||||
case 'channel':
|
||||
isYoutubeLink = true
|
||||
break
|
||||
case 'hashtag':
|
||||
// TODO: Implement a hashtag related view
|
||||
// isYoutubeLink is already `false`
|
||||
isYoutubeLink = true
|
||||
break
|
||||
|
||||
case 'invalid_url':
|
||||
|
||||
@@ -6,7 +6,7 @@ import FtProfileSelector from '../ft-profile-selector/ft-profile-selector.vue'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import { IpcChannels } from '../../../constants'
|
||||
import { openInternalPath, showToast } from '../../helpers/utils'
|
||||
import { openInternalPath } from '../../helpers/utils'
|
||||
import { clearLocalSearchSuggestionsSession, getLocalSearchSuggestions } from '../../helpers/api/local'
|
||||
import { invidiousAPICall } from '../../helpers/api/invidious'
|
||||
|
||||
@@ -164,13 +164,12 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
case 'hashtag': {
|
||||
// TODO: Implement a hashtag related view
|
||||
let message = 'Hashtags have not yet been implemented, try again later'
|
||||
if (this.$t(message) && this.$t(message) !== '') {
|
||||
message = this.$t(message)
|
||||
}
|
||||
const { hashtag } = result
|
||||
openInternalPath({
|
||||
path: `/hashtag/${encodeURIComponent(hashtag)}`,
|
||||
doCreateNewWindow
|
||||
})
|
||||
|
||||
showToast(message)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,7 @@ export default defineComponent({
|
||||
descriptionText = descriptionText.replaceAll(/&redirect-token.+?(?=")/g, '')
|
||||
descriptionText = descriptionText.replaceAll(/&redir_token.+?(?=")/g, '')
|
||||
descriptionText = descriptionText.replaceAll('href="/', 'href="https://www.youtube.com/')
|
||||
// TODO: Implement hashtag support
|
||||
descriptionText = descriptionText.replaceAll('href="/hashtag/', 'href="freetube://')
|
||||
descriptionText = descriptionText.replaceAll('href="/hashtag/', 'href="https://wwww.youtube.com/hashtag/')
|
||||
descriptionText = descriptionText.replaceAll('yt.www.watch.player.seekTo', 'changeDuration')
|
||||
|
||||
return descriptionText
|
||||
|
||||
@@ -502,7 +502,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16, options = { looseChanne
|
||||
} else {
|
||||
const { text, bold, italics, strikethrough, endpoint } = run
|
||||
|
||||
if (endpoint && !text.startsWith('#')) {
|
||||
if (endpoint) {
|
||||
switch (endpoint.metadata.page_type) {
|
||||
case 'WEB_PAGE_TYPE_WATCH':
|
||||
if (timestampRegex.test(text)) {
|
||||
@@ -527,6 +527,9 @@ export function parseLocalTextRuns(runs, emojiSize = 16, options = { looseChanne
|
||||
case 'WEB_PAGE_TYPE_PLAYLIST':
|
||||
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
|
||||
break
|
||||
case 'WEB_PAGE_TYPE_BROWSE':
|
||||
parsedRuns.push(`<a href="https://www.youtube.com${endpoint.metadata.url}">${text}</a>`)
|
||||
break
|
||||
case 'WEB_PAGE_TYPE_UNKNOWN':
|
||||
default: {
|
||||
const url = new URL((endpoint.dialog?.type === 'ConfirmDialog' && endpoint.dialog.confirm_button.endpoint.payload.url) || endpoint.payload.url)
|
||||
@@ -748,3 +751,8 @@ function parseLocalAttachment(attachment) {
|
||||
console.error('unknown type')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHashtagLocal(hashtag) {
|
||||
const innertube = await createInnertube()
|
||||
return await innertube.getHashtag(hashtag)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import Search from '../views/Search/Search.vue'
|
||||
import Playlist from '../views/Playlist/Playlist.vue'
|
||||
import Channel from '../views/Channel/Channel.vue'
|
||||
import Watch from '../views/Watch/Watch.vue'
|
||||
import Hashtag from '../views/Hashtag/Hashtag.vue'
|
||||
|
||||
class CustomRouter extends Router {
|
||||
push(location) {
|
||||
@@ -165,6 +166,13 @@ const router = new CustomRouter({
|
||||
title: 'Watch'
|
||||
},
|
||||
component: Watch
|
||||
},
|
||||
{
|
||||
path: '/hashtag/:hashtag',
|
||||
meta: {
|
||||
title: 'Hashtag'
|
||||
},
|
||||
component: Hashtag
|
||||
}
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
|
||||
@@ -317,10 +317,12 @@ const actions = {
|
||||
const channelPattern =
|
||||
/^\/(?:(?:channel|user|c)\/)?(?<channelId>[^/]+)(?:\/(?<tab>join|featured|videos|live|streams|playlists|about|community|channels))?\/?$/
|
||||
|
||||
const hashtagPattern = /^\/hashtag\/(?<tag>[^#&/?]+)$/
|
||||
|
||||
const typePatterns = new Map([
|
||||
['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/],
|
||||
['search', /^\/results\/?$/],
|
||||
['hashtag', /^\/hashtag\/([^#&/?]+)$/],
|
||||
['hashtag', hashtagPattern],
|
||||
['channel', channelPattern]
|
||||
])
|
||||
|
||||
@@ -381,8 +383,12 @@ const actions = {
|
||||
}
|
||||
|
||||
case 'hashtag': {
|
||||
const match = url.pathname.match(hashtagPattern)
|
||||
const hashtag = match.groups.tag
|
||||
|
||||
return {
|
||||
urlType: 'hashtag'
|
||||
urlType: 'hashtag',
|
||||
hashtag
|
||||
}
|
||||
}
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.getNextPage {
|
||||
background-color: var(--search-bar-color);
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
line-height: 45px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.getNextPage:hover, .getNextPage:focus {
|
||||
background-color: var(--side-nav-hover-color);
|
||||
}
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto 20px;
|
||||
position: relative;
|
||||
width: 85%;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import FtCard from '../../components/ft-card/ft-card.vue'
|
||||
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
|
||||
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
|
||||
import FtLoader from '../../components/ft-loader/ft-loader.vue'
|
||||
import packageDetails from '../../../../package.json'
|
||||
import { getHashtagLocal, parseLocalListVideo } from '../../helpers/api/local'
|
||||
import { isNullOrEmpty } from '../../helpers/utils'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Hashtag',
|
||||
components: {
|
||||
'ft-card': FtCard,
|
||||
'ft-element-list': FtElementList,
|
||||
'ft-flex-box': FtFlexBox,
|
||||
'ft-loader': FtLoader
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
hashtag: '',
|
||||
hashtagContinuationData: null,
|
||||
videos: [],
|
||||
apiUsed: 'local',
|
||||
isLoading: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
backendPreference: function () {
|
||||
return this.$store.getters.getBackendPreference
|
||||
},
|
||||
|
||||
backendFallback: function () {
|
||||
return this.$store.getters.getBackendFallback
|
||||
},
|
||||
|
||||
showFetchMoreButton() {
|
||||
return !isNullOrEmpty(this.hashtagContinuationData)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.isLoading = true
|
||||
this.hashtag = ''
|
||||
this.hashtagContinuationData = null
|
||||
this.videos = []
|
||||
this.apiUsed = 'local'
|
||||
this.getHashtag()
|
||||
}
|
||||
},
|
||||
mounted: function() {
|
||||
this.isLoading = true
|
||||
this.getHashtag()
|
||||
},
|
||||
methods: {
|
||||
getHashtag: async function() {
|
||||
const hashtag = this.$route.params.hashtag
|
||||
if (this.backendFallback || this.backendPreference === 'local') {
|
||||
await this.getLocalHashtag(hashtag)
|
||||
} else {
|
||||
this.getInvidiousHashtag(hashtag)
|
||||
}
|
||||
document.title = `${this.hashtag} - ${packageDetails.productName}`
|
||||
},
|
||||
getInvidiousHashtag: function(hashtag) {
|
||||
this.hashtag = '#' + hashtag
|
||||
this.apiUsed = 'Invidious'
|
||||
this.isLoading = false
|
||||
},
|
||||
|
||||
getLocalHashtag: async function(hashtag) {
|
||||
const hashtagData = await getHashtagLocal(hashtag)
|
||||
this.hashtag = hashtagData.header.hashtag
|
||||
this.videos = hashtagData.contents.contents.filter(item =>
|
||||
item.type !== 'ContinuationItem'
|
||||
).map(item =>
|
||||
parseLocalListVideo(item.content)
|
||||
)
|
||||
this.apiUsed = 'local'
|
||||
this.hashtagContinuationData = hashtagData.has_continuation ? hashtagData : null
|
||||
this.isLoading = false
|
||||
},
|
||||
|
||||
getLocalHashtagMore: async function() {
|
||||
const continuation = await this.hashtagContinuationData.getContinuationData()
|
||||
const newVideos = continuation.on_response_received_actions[0].contents.filter(item =>
|
||||
item.type !== 'ContinuationItem'
|
||||
).map(item =>
|
||||
parseLocalListVideo(item.content)
|
||||
)
|
||||
this.hashtagContinuationData = continuation.has_continuation ? continuation : null
|
||||
this.videos = this.videos.concat(newVideos)
|
||||
},
|
||||
|
||||
handleFetchMore: function() {
|
||||
if (this.apiUsed === 'local') {
|
||||
this.getLocalHashtagMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div>
|
||||
<ft-loader
|
||||
v-if="isLoading"
|
||||
:fullscreen="true"
|
||||
/>
|
||||
<div v-else>
|
||||
<ft-card>
|
||||
<h3>{{ hashtag }}</h3>
|
||||
<div
|
||||
class="elementList"
|
||||
>
|
||||
<ft-element-list
|
||||
v-if="backendFallback || backendPreference === 'local' && videos.length > 0"
|
||||
:data="videos"
|
||||
/>
|
||||
<ft-flex-box
|
||||
v-else-if="backendFallback || backendPreference === 'local' && videos.length === 0"
|
||||
>
|
||||
<p
|
||||
class="message"
|
||||
>
|
||||
{{ $t("Hashtag.This hashtag does not currently have any videos") }}
|
||||
</p>
|
||||
</ft-flex-box>
|
||||
<ft-flex-box
|
||||
v-else
|
||||
>
|
||||
<p
|
||||
class="message"
|
||||
>
|
||||
{{ $t("Hashtag.You can only view hashtag pages through the Local API") }}
|
||||
</p>
|
||||
</ft-flex-box>
|
||||
</div>
|
||||
<div
|
||||
v-if="showFetchMoreButton"
|
||||
class="getNextPage"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleFetchMore"
|
||||
@keydown.space.prevent="handleFetchMore"
|
||||
@keydown.enter.prevent="handleFetchMore"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'search']" /> {{ $t("Search Filters.Fetch more results") }}
|
||||
</div>
|
||||
</ft-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script src="./Hashtag.js" />
|
||||
<style scoped src="./Hashtag.css" />
|
||||
@@ -872,6 +872,11 @@ Downloading failed: 'There was an issue downloading "{videoTitle}"'
|
||||
Screenshot Success: Saved screenshot as "{filePath}"
|
||||
Screenshot Error: Screenshot failed. {error}
|
||||
|
||||
Hashtag:
|
||||
Hashtag: Hashtag
|
||||
This hashtag does not currently have any videos: This hashtag does not currently
|
||||
have any videos
|
||||
You can only view hashtag pages through the Local API: You can only view hashtag pages through the Local API
|
||||
Yes: Yes
|
||||
No: No
|
||||
Ok: Ok
|
||||
|
||||
Reference in New Issue
Block a user