diff --git a/src/renderer/components/ft-refresh-widget/ft-refresh-widget.js b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.js index 24ca4e1f8495c..491420f2dba3c 100644 --- a/src/renderer/components/ft-refresh-widget/ft-refresh-widget.js +++ b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.js @@ -19,11 +19,29 @@ export default defineComponent({ title: { type: String, required: true + }, + updatedChannelsCount: { + type: Number, + default: 0 + }, + totalChannelsCount: { + type: Number, + default: 0 } }, computed: { isSideNavOpen: function () { return this.$store.getters.getIsSideNavOpen + }, + lastRefreshTimestampLabel: function () { + return this.updatedChannelsCount === this.totalChannelsCount + ? this.$t('Feed.Feed Last Updated', { feedName: this.title, date: this.lastRefreshTimestamp }) + : this.$t('Feed.Feed Last Updated For Channels', { + feedName: this.title, + date: this.lastRefreshTimestamp, + someChannelsCount: this.updatedChannelsCount, + allChannelsCount: this.totalChannelsCount + }) } } }) diff --git a/src/renderer/components/ft-refresh-widget/ft-refresh-widget.vue b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.vue index 564f1cae8402c..4a946cc42452f 100644 --- a/src/renderer/components/ft-refresh-widget/ft-refresh-widget.vue +++ b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.vue @@ -9,7 +9,7 @@ v-if="lastRefreshTimestamp" class="lastRefreshTimestamp" > - {{ $t('Feed.Feed Last Updated', { feedName: title, date: lastRefreshTimestamp }) }} + {{ lastRefreshTimestampLabel }}

{ - return cacheEntry.posts != null - }) + return this.nonNullCacheEntriesCount < this.cacheEntriesForAllActiveProfileChannels.length + }, + + nonNullCacheEntriesCount() { + return this.cacheEntriesForAllActiveProfileChannels + .filter((cacheEntry) => cacheEntry.posts != null).length }, activeSubscriptionList: function () { @@ -73,20 +77,19 @@ export default defineComponent({ }, watch: { activeProfile: async function (_) { - this.isLoading = true - this.loadPostsFromCacheSometimes() + this.loadPostsFromCacheOrServer() }, }, mounted: async function () { - this.isLoading = true - - this.loadPostsFromCacheSometimes() + this.loadPostsFromCacheOrServer() }, methods: { - loadPostsFromCacheSometimes() { + async loadPostsFromCacheOrServer() { + this.isLoading = true // This method is called on view visible - if (this.postCacheForAllActiveProfileChannelsPresent) { - this.loadPostsFromCacheForAllActiveProfileChannels() + if (!this.fetchSubscriptionsAutomatically || this.postCacheForAllActiveProfileChannelsPresent) { + this.updatedChannelsCount = this.nonNullCacheEntriesCount + this.loadPostsFromCacheForActiveProfileChannels() if (this.cacheEntriesForAllActiveProfileChannels.length > 0) { let minTimestamp = null this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { @@ -99,14 +102,12 @@ export default defineComponent({ return } - this.maybeLoadPostsForSubscriptionsFromRemote() + this.loadPostsForSubscriptionsFromRemote() }, - async loadPostsFromCacheForAllActiveProfileChannels() { + async loadPostsFromCacheForActiveProfileChannels() { const postList = [] - this.activeSubscriptionList.forEach((channel) => { - const channelCacheEntry = this.$store.getters.getPostsCacheByChannel(channel.id) - + this.cacheEntriesForAllActiveProfileChannels.forEach((channelCacheEntry) => { postList.push(...channelCacheEntry.posts) }) @@ -119,6 +120,8 @@ export default defineComponent({ }, loadPostsForSubscriptionsFromRemote: async function () { + this.updatedChannelsCount = this.activeSubscriptionList.length + if (this.activeSubscriptionList.length === 0) { this.isLoading = false this.postList = [] @@ -165,17 +168,6 @@ export default defineComponent({ this.updateShowProgressBar(false) }, - maybeLoadPostsForSubscriptionsFromRemote: async function () { - if (this.fetchSubscriptionsAutomatically) { - // `this.isLoading = false` is called inside `loadPostsForSubscriptionsFromRemote` when needed - await this.loadPostsForSubscriptionsFromRemote() - } else { - this.postList = [] - this.attemptedFetch = false - this.isLoading = false - } - }, - getChannelPostsLocal: async function (channel) { try { const entries = await getLocalChannelCommunity(channel.id) diff --git a/src/renderer/components/subscriptions-community/subscriptions-community.vue b/src/renderer/components/subscriptions-community/subscriptions-community.vue index f9c3ede16490d..37415d4c43e2e 100644 --- a/src/renderer/components/subscriptions-community/subscriptions-community.vue +++ b/src/renderer/components/subscriptions-community/subscriptions-community.vue @@ -7,6 +7,8 @@ :is-community="true" :initial-data-limit="20" :last-refresh-timestamp="lastCommunityRefreshTimestamp" + :updated-channels-count="updatedChannelsCount" + :total-channels-count="activeSubscriptionList.length" :title="$t('Global.Community')" @refresh="loadPostsForSubscriptionsFromRemote" /> diff --git a/src/renderer/components/subscriptions-live/subscriptions-live.js b/src/renderer/components/subscriptions-live/subscriptions-live.js index 3b5fc854a479a..4fd15dadb4016 100644 --- a/src/renderer/components/subscriptions-live/subscriptions-live.js +++ b/src/renderer/components/subscriptions-live/subscriptions-live.js @@ -5,7 +5,7 @@ import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue import { copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' import { invidiousAPICall } from '../../helpers/api/invidious' import { getLocalChannelLiveStreams } from '../../helpers/api/local' -import { addPublishedDatesInvidious, addPublishedDatesLocal, parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' +import { addPublishedDatesInvidious, addPublishedDatesLocal, parseYouTubeRSSFeed, updateVideoListAfterProcessing, loadSubscriptionVideosFromCacheOrServer } from '../../helpers/subscriptions' export default defineComponent({ name: 'SubscriptionsLive', @@ -18,6 +18,7 @@ export default defineComponent({ videoList: [], errorChannels: [], attemptedFetch: false, + updatedChannelsCount: 0 } }, computed: { @@ -58,9 +59,11 @@ export default defineComponent({ if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false } if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false } - return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => { - return cacheEntry.videos != null - }) + return this.nonNullCacheEntriesCount < this.cacheEntriesForAllActiveProfileChannels.length + }, + nonNullCacheEntriesCount() { + return this.cacheEntriesForAllActiveProfileChannels + .filter((cacheEntry) => cacheEntry.videos != null).length }, activeSubscriptionList: function () { @@ -78,46 +81,35 @@ export default defineComponent({ watch: { activeProfile: async function (_) { this.isLoading = true - this.loadVideosFromCacheSometimes() + this.loadSubscriptionVideosFromCacheOrServer() }, }, mounted: async function () { this.isLoading = true - - this.loadVideosFromCacheSometimes() + this.loadSubscriptionVideosFromCacheOrServer() }, methods: { - loadVideosFromCacheSometimes() { - // This method is called on view visible - if (this.videoCacheForAllActiveProfileChannelsPresent) { - this.loadVideosFromCacheForAllActiveProfileChannels() - if (this.cacheEntriesForAllActiveProfileChannels.length > 0) { - let minTimestamp = null - this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { - if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) { - minTimestamp = cacheEntry.timestamp - } - }) - this.updateLastLiveRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp }) - } + loadSubscriptionVideosFromCacheOrServer: function () { + const videoList = loadSubscriptionVideosFromCacheOrServer( + this.cacheEntriesForAllActiveProfileChannels, + this.videoCacheForAllActiveProfileChannelsPresent, + this.updateTimestampByProfile, + this.activeProfileId + ) + if (videoList) { + this.isLoading = false + this.updatedChannelsCount = this.nonNullCacheEntriesCount return } - this.maybeLoadVideosForSubscriptionsFromRemote() + this.loadVideosForSubscriptionsFromRemote() }, - - async loadVideosFromCacheForAllActiveProfileChannels() { - const videoList = [] - this.activeSubscriptionList.forEach((channel) => { - const channelCacheEntry = this.$store.getters.getLiveCacheByChannel(channel.id) - - videoList.push(...channelCacheEntry.videos) - }) - this.videoList = updateVideoListAfterProcessing(videoList) - this.isLoading = false + updateTimestampByProfile(payload) { + this.updateLastLiveRefreshTimestampByProfile(payload) }, - loadVideosForSubscriptionsFromRemote: async function () { + this.updatedChannelsCount = this.activeSubscriptionList.length + if (this.activeSubscriptionList.length === 0) { this.isLoading = false this.videoList = [] @@ -176,17 +168,6 @@ export default defineComponent({ this.updateShowProgressBar(false) }, - maybeLoadVideosForSubscriptionsFromRemote: async function () { - if (this.fetchSubscriptionsAutomatically) { - // `this.isLoading = false` is called inside `loadVideosForSubscriptionsFromRemote` when needed - await this.loadVideosForSubscriptionsFromRemote() - } else { - this.videoList = [] - this.attemptedFetch = false - this.isLoading = false - } - }, - getChannelLiveLocal: async function (channel, failedAttempts = 0) { try { const entries = await getLocalChannelLiveStreams(channel.id) diff --git a/src/renderer/components/subscriptions-live/subscriptions-live.vue b/src/renderer/components/subscriptions-live/subscriptions-live.vue index 89be8fbd81c82..aa8938f909210 100644 --- a/src/renderer/components/subscriptions-live/subscriptions-live.vue +++ b/src/renderer/components/subscriptions-live/subscriptions-live.vue @@ -5,6 +5,8 @@ :error-channels="errorChannels" :attempted-fetch="attemptedFetch" :last-refresh-timestamp="lastLiveRefreshTimestamp" + :updated-channels-count="updatedChannelsCount" + :total-channels-count="activeSubscriptionList.length" :title="$t('Global.Live')" @refresh="loadVideosForSubscriptionsFromRemote" /> diff --git a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js index a196732b18de7..5ba1eca3cf032 100644 --- a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js +++ b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js @@ -2,7 +2,7 @@ import { defineComponent } from 'vue' import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' -import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' +import { parseYouTubeRSSFeed, updateVideoListAfterProcessing, loadSubscriptionVideosFromCacheOrServer } from '../../helpers/subscriptions' import { copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' export default defineComponent({ @@ -16,6 +16,7 @@ export default defineComponent({ videoList: [], errorChannels: [], attemptedFetch: false, + updatedChannelsCount: 0 } }, computed: { @@ -56,9 +57,11 @@ export default defineComponent({ if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false } if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false } - return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => { - return cacheEntry.videos != null - }) + return this.nonNullCacheEntriesCount < this.cacheEntriesForAllActiveProfileChannels.length + }, + nonNullCacheEntriesCount() { + return this.cacheEntriesForAllActiveProfileChannels + .filter((cacheEntry) => cacheEntry.videos != null).length }, activeSubscriptionList: function () { @@ -71,48 +74,19 @@ export default defineComponent({ }, watch: { activeProfile: async function (_) { - this.isLoading = true - this.loadVideosFromCacheSometimes() + loadSubscriptionVideosFromCacheOrServer(this) }, }, mounted: async function () { - this.isLoading = true - - this.loadVideosFromCacheSometimes() + loadSubscriptionVideosFromCacheOrServer(this) }, methods: { - loadVideosFromCacheSometimes() { - // This method is called on view visible - - if (this.videoCacheForAllActiveProfileChannelsPresent) { - this.loadVideosFromCacheForAllActiveProfileChannels() - if (this.cacheEntriesForAllActiveProfileChannels.length > 0) { - let minTimestamp = null - this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { - if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) { - minTimestamp = cacheEntry.timestamp - } - }) - this.updateLastShortRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp }) - } - return - } - - this.maybeLoadVideosForSubscriptionsFromRemote() + updateTimestampByProfile(payload) { + this.updateLastShortRefreshTimestampByProfile(payload) }, - - async loadVideosFromCacheForAllActiveProfileChannels() { - const videoList = [] - this.activeSubscriptionList.forEach((channel) => { - const channelCacheEntry = this.$store.getters.getShortsCacheByChannel(channel.id) - - videoList.push(...channelCacheEntry.videos) - }) - this.videoList = updateVideoListAfterProcessing(videoList) - this.isLoading = false - }, - loadVideosForSubscriptionsFromRemote: async function () { + this.updatedChannelsCount = this.activeSubscriptionList.length + if (this.activeSubscriptionList.length === 0) { this.isLoading = false this.videoList = [] @@ -154,17 +128,6 @@ export default defineComponent({ this.updateShowProgressBar(false) }, - maybeLoadVideosForSubscriptionsFromRemote: async function () { - if (this.fetchSubscriptionsAutomatically) { - // `this.isLoading = false` is called inside `loadVideosForSubscriptionsFromRemote` when needed - await this.loadVideosForSubscriptionsFromRemote() - } else { - this.videoList = [] - this.attemptedFetch = false - this.isLoading = false - } - }, - getChannelShortsLocal: async function (channel, failedAttempts = 0) { const playlistId = channel.id.replace('UC', 'UUSH') const feedUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}` diff --git a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.vue b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.vue index 0aa6504539cf4..580d5830a529a 100644 --- a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.vue +++ b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.vue @@ -5,6 +5,8 @@ :error-channels="errorChannels" :attempted-fetch="attemptedFetch" :last-refresh-timestamp="lastShortRefreshTimestamp" + :updated-channels-count="updatedChannelsCount" + :total-channels-count="activeSubscriptionList.length" :title="$t('Global.Shorts')" @refresh="loadVideosForSubscriptionsFromRemote" /> diff --git a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.js b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.js index 9128e3fa240a4..84a09988a4a87 100644 --- a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.js +++ b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.js @@ -51,6 +51,14 @@ export default defineComponent({ title: { type: String, required: true + }, + updatedChannelsCount: { + type: Number, + default: 0 + }, + totalChannelsCount: { + type: Number, + default: 0 } }, data: function () { diff --git a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.vue b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.vue index 0918581a66267..aeeab70fe50ac 100644 --- a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.vue +++ b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.vue @@ -58,6 +58,8 @@ diff --git a/src/renderer/components/subscriptions-videos/subscriptions-videos.js b/src/renderer/components/subscriptions-videos/subscriptions-videos.js index c0e5917722c34..3aee9ec4ae832 100644 --- a/src/renderer/components/subscriptions-videos/subscriptions-videos.js +++ b/src/renderer/components/subscriptions-videos/subscriptions-videos.js @@ -5,7 +5,7 @@ import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue import { copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' import { invidiousAPICall } from '../../helpers/api/invidious' import { getLocalChannelVideos } from '../../helpers/api/local' -import { addPublishedDatesInvidious, addPublishedDatesLocal, parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' +import { addPublishedDatesInvidious, addPublishedDatesLocal, parseYouTubeRSSFeed, updateVideoListAfterProcessing, loadSubscriptionVideosFromCacheOrServer } from '../../helpers/subscriptions' export default defineComponent({ name: 'SubscriptionsVideos', @@ -18,6 +18,7 @@ export default defineComponent({ videoList: [], errorChannels: [], attemptedFetch: false, + updatedChannelsCount: 0 } }, computed: { @@ -66,9 +67,11 @@ export default defineComponent({ if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false } if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false } - return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => { - return cacheEntry.videos != null - }) + return this.nonNullCacheEntriesCount < this.cacheEntriesForAllActiveProfileChannels.length + }, + nonNullCacheEntriesCount() { + return this.cacheEntriesForAllActiveProfileChannels + .filter((cacheEntry) => cacheEntry.videos != null).length }, activeSubscriptionList: function () { @@ -81,47 +84,19 @@ export default defineComponent({ }, watch: { activeProfile: async function (_) { - this.isLoading = true - this.loadVideosFromCacheSometimes() + loadSubscriptionVideosFromCacheOrServer(this) }, }, mounted: async function () { - this.isLoading = true - - this.loadVideosFromCacheSometimes() + loadSubscriptionVideosFromCacheOrServer(this) }, methods: { - loadVideosFromCacheSometimes() { - // This method is called on view visible - if (this.videoCacheForAllActiveProfileChannelsPresent) { - this.loadVideosFromCacheForAllActiveProfileChannels() - if (this.cacheEntriesForAllActiveProfileChannels.length > 0) { - let minTimestamp = null - this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { - if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) { - minTimestamp = cacheEntry.timestamp - } - }) - this.updateLastVideoRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp }) - } - return - } - - this.maybeLoadVideosForSubscriptionsFromRemote() + updateTimestampByProfile(payload) { + this.updateLastVideoRefreshTimestampByProfile(payload) }, - - async loadVideosFromCacheForAllActiveProfileChannels() { - const videoList = [] - this.activeSubscriptionList.forEach((channel) => { - const channelCacheEntry = this.$store.getters.getVideoCacheByChannel(channel.id) - - videoList.push(...channelCacheEntry.videos) - }) - this.videoList = updateVideoListAfterProcessing(videoList) - this.isLoading = false - }, - loadVideosForSubscriptionsFromRemote: async function () { + this.updatedChannelsCount = this.activeSubscriptionList.length + if (this.activeSubscriptionList.length === 0) { this.isLoading = false this.videoList = [] @@ -180,17 +155,6 @@ export default defineComponent({ this.updateShowProgressBar(false) }, - maybeLoadVideosForSubscriptionsFromRemote: async function () { - if (this.fetchSubscriptionsAutomatically) { - // `this.isLoading = false` is called inside `loadVideosForSubscriptionsFromRemote` when needed - await this.loadVideosForSubscriptionsFromRemote() - } else { - this.videoList = [] - this.attemptedFetch = false - this.isLoading = false - } - }, - getChannelVideosLocalScraper: async function (channel, failedAttempts = 0) { try { const videos = await getLocalChannelVideos(channel.id) diff --git a/src/renderer/components/subscriptions-videos/subscriptions-videos.vue b/src/renderer/components/subscriptions-videos/subscriptions-videos.vue index 42aaa5d014122..f72280daf0c8a 100644 --- a/src/renderer/components/subscriptions-videos/subscriptions-videos.vue +++ b/src/renderer/components/subscriptions-videos/subscriptions-videos.vue @@ -4,6 +4,8 @@ :video-list="videoList" :error-channels="errorChannels" :last-refresh-timestamp="lastVideoRefreshTimestamp" + :updated-channels-count="updatedChannelsCount" + :total-channels-count="activeSubscriptionList.length" :attempted-fetch="attemptedFetch" :title="$t('Global.Videos')" @refresh="loadVideosForSubscriptionsFromRemote" diff --git a/src/renderer/helpers/subscriptions.js b/src/renderer/helpers/subscriptions.js index 8ee8d78d8f2ba..1121aeb68389e 100644 --- a/src/renderer/helpers/subscriptions.js +++ b/src/renderer/helpers/subscriptions.js @@ -1,6 +1,10 @@ import store from '../store/index' import { calculatePublishedDate } from './utils' +function fetchSubscriptionsAutomatically() { + return store.getters.getFetchSubscriptionsAutomatically +} + /** * Filtering and sort based on user preferences * @param {any[]} videos @@ -140,3 +144,37 @@ export function addPublishedDatesInvidious(videos) { return video }) } + +export async function loadSubscriptionVideosFromCacheOrServer( + cacheEntriesForAllActiveProfileChannels, + videoCacheForAllActiveProfileChannelsPresent, + updateTimestampByProfile, + activeProfileId +) { + if (fetchSubscriptionsAutomatically() || videoCacheForAllActiveProfileChannelsPresent) { + const videoList = loadVideosFromCacheForActiveProfileChannels(cacheEntriesForAllActiveProfileChannels) + if (cacheEntriesForAllActiveProfileChannels.length > 0) { + let minTimestamp = null + cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { + if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) { + minTimestamp = cacheEntry.timestamp + } + }) + updateTimestampByProfile({ profileId: activeProfileId, timestamp: minTimestamp }) + } + + return videoList + } + + return null +} + +export async function loadVideosFromCacheForActiveProfileChannels(cacheEntriesForAllActiveProfileChannels) { + const videoList = [] + cacheEntriesForAllActiveProfileChannels.forEach((channelCacheEntry) => { + if (channelCacheEntry.videos != null) { + videoList.push(...channelCacheEntry.videos) + } + }) + return updateVideoListAfterProcessing(videoList) +} diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index ee78e9b59a63a..9e6fc78d2e751 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -130,6 +130,7 @@ Trending: Most Popular: Most Popular Feed: Feed Last Updated: '{feedName} feed last updated: {date}' + Feed Last Updated For Channels: '{feedName} feed last updated for {someChannelsCount}/{allChannelsCount} channels: {date}' Refresh Feed: Refresh {subscriptionName} Playlists: Playlists User Playlists: