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:
ChunkyProgrammer
2023-05-13 13:27:41 +02:00
committed by GitHub
co-authored by absidue
parent e32417b9cb
commit a4d45b5fa8
11 changed files with 216 additions and 24 deletions
+7 -8
View File
@@ -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
}
+1 -4
View File
@@ -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
View File
@@ -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
+9 -1
View File
@@ -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)
}
+8
View File
@@ -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) {
+8 -2
View File
@@ -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
}
}
/*
+19
View File
@@ -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%;
}
+100
View File
@@ -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()
}
}
}
})
+52
View File
@@ -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" />
+5
View File
@@ -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