@saooti/octopus-sdk 41.7.3 → 41.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/CHANGELOG.md +18 -0
  3. package/index.ts +3 -2
  4. package/package.json +1 -1
  5. package/src/api/emissionApi.ts +44 -1
  6. package/src/api/podcastApi.ts +27 -5
  7. package/src/components/composable/useRights.ts +1 -1
  8. package/src/components/composable/useSeasonsManagement.ts +43 -0
  9. package/src/components/display/podcasts/PodcastFilterList.vue +100 -18
  10. package/src/components/display/podcasts/PodcastInlineListTemplate.vue +4 -1
  11. package/src/components/display/podcasts/PodcastList.vue +5 -1
  12. package/src/components/display/podcasts/PodcastModuleBox.vue +21 -4
  13. package/src/components/display/podcasts/PodcastSwiperList.vue +9 -3
  14. package/src/components/form/ClassicRadio.vue +13 -14
  15. package/src/components/misc/TopBar.vue +7 -1
  16. package/src/components/pages/EmissionPage.vue +22 -10
  17. package/src/locale/de.json +7 -1
  18. package/src/locale/en.json +7 -1
  19. package/src/locale/es.json +7 -1
  20. package/src/locale/fr.json +7 -1
  21. package/src/locale/it.json +7 -1
  22. package/src/locale/sl.json +7 -1
  23. package/src/stores/class/general/emission.ts +20 -0
  24. package/src/stores/class/general/podcast.ts +17 -0
  25. package/src/style/general.scss +7 -0
  26. package/tests/api/podcastApi.spec.ts +43 -0
  27. package/tests/components/composable/useSeasonsManagement.spec.ts +35 -0
  28. package/tests/components/display/podcasts/PodcastFilterList.spec.ts +33 -0
  29. package/tests/components/display/podcasts/PodcastInlineListTemplate.spec.ts +23 -0
  30. package/tests/components/display/podcasts/PodcastModuleBox.spec.ts +49 -22
  31. package/tests/components/pages/EmissionPage.spec.ts +86 -0
@@ -8,7 +8,8 @@
8
8
  "Bash(npm audit:*)",
9
9
  "Bash(python3:*)",
10
10
  "Bash(node --version:*)",
11
- "Bash(npm:*)"
11
+ "Bash(npm:*)",
12
+ "Bash(npx vitest:*)"
12
13
  ]
13
14
  }
14
15
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 41.8.0 (18/03/2026)
4
+
5
+ **Features**
6
+
7
+ - **14083** - Ajout des fonctionnalités de saisons et types d'épisodes
8
+ - Ajout des propriétés relatives aux saisons sur les émissions et les épisodes
9
+ - `PodcastFilterList` permet de regrouper les épisodes en saisons
10
+ - `EmissionPage` affiche ses épisodes en saisons si définies
11
+ - `PodcastModuleBox` affiche le numéro de saison et d'épisode du podcast si
12
+ définis et que le `seasonMode` permet leur affichage
13
+ - Ajout d'une classe `required` pour afficher une asterisque sur les champs
14
+ requis
15
+
16
+ **Fix**
17
+
18
+ - **14291** - Activation du bouton de génération de la transcription suite à
19
+ correction des droits
20
+
3
21
  ## 41.7.3 (11/03/2026)
4
22
 
5
23
  **Features**
package/index.ts CHANGED
@@ -134,6 +134,7 @@ export { useSharePlatforms, SharePlatformName, type SharePlatform } from "./src/
134
134
  export { useSharePath } from "./src/components/composable/share/useSharePath.ts";
135
135
  export { useOrgaComputed } from "./src/components/composable/useOrgaComputed.ts";
136
136
  export { useSeoTitleUrl } from "./src/components/composable/route/useSeoTitleUrl.ts";
137
+ export { useSeasonsManagement } from "./src/components/composable/useSeasonsManagement.ts";
137
138
 
138
139
  //helper
139
140
  import domHelper from "./src/helper/domHelper.ts";
@@ -166,8 +167,8 @@ export { playlistApi } from "./src/api/playlistApi.ts";
166
167
  export { podcastApi, PodcastSort, type PodcastSearchOptions } from "./src/api/podcastApi.ts";
167
168
 
168
169
  // Types
169
- export { type Emission, emptyEmissionData } from "./src/stores/class/general/emission.ts";
170
- export { type Podcast, type PodcastAvailability } from "./src/stores/class/general/podcast.ts";
170
+ export { type Emission, SeasonMode, emptyEmissionData } from "./src/stores/class/general/emission.ts";
171
+ export { type Podcast, type PodcastAvailability, PodcastType } from "./src/stores/class/general/podcast.ts";
171
172
  export { type Playlist, type PlaylistRule } from "./src/stores/class/general/playlist.ts";
172
173
  export { type Annotations } from "./src/stores/class/general";
173
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saooti/octopus-sdk",
3
- "version": "41.7.3",
3
+ "version": "41.8.0",
4
4
  "private": false,
5
5
  "description": "Javascript SDK for using octopus",
6
6
  "author": "Saooti",
@@ -2,6 +2,7 @@ import classicApi from "./classicApi";
2
2
  import { ModuleApi } from "./apiConnection";
3
3
  import { mapFromGetAll } from "./apiUtils";
4
4
  import { Emission } from "@/stores/class/general/emission";
5
+ import { ListClassicReturn } from "@/stores/class/general/listReturn";
5
6
 
6
7
  /**
7
8
  * Retrieve an emission by ID
@@ -25,7 +26,49 @@ async function getAllById(emissionIds: Array<number>): Promise<Record<string, Em
25
26
  return mapFromGetAll(emissionIds, get, 'emissionId');
26
27
  }
27
28
 
29
+
30
+ /**
31
+ * Search for emissions
32
+ * @param query A filter on the name of the emission
33
+ * @param options Optional options to filter the results
34
+ */
35
+ async function search(query?: string, options?: {
36
+ /** The index of the first element to retrieve */
37
+ first?: number;
38
+ /** The number of elements to retrieve */
39
+ size?: number;
40
+ specialTreatment?: boolean;
41
+ distributedBy?: string;
42
+ /** Filter by organisation */
43
+ organisationId?: string|string[];
44
+ }): Promise<ListClassicReturn<Emission>> {
45
+ return classicApi.fetchData<ListClassicReturn<Emission>>({
46
+ api: ModuleApi.DEFAULT,
47
+ path: "emission/search",
48
+ parameters: {
49
+ query,
50
+ first: options?.first ?? 0,
51
+ size: options?.size ?? 10,
52
+ distributedBy: options?.distributedBy,
53
+ organisationId: options?.organisationId
54
+ },
55
+ specialTreatement: options?.specialTreatment ?? true
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Remove seasons data from emission
61
+ * @param emissionId The ID of the emission for which to remove the data
62
+ */
63
+ async function resetSeasons(emissionId: number): Promise<void> {
64
+ return classicApi.putData({
65
+ api: ModuleApi.DEFAULT,
66
+ path: 'emission/seasons/reset/' + emissionId
67
+ });
68
+ }
28
69
  export const emissionApi = {
29
70
  get,
30
- getAllById
71
+ getAllById,
72
+ search,
73
+ resetSeasons
31
74
  };
@@ -1,4 +1,4 @@
1
- import { Podcast, PodcastProcessingStatus, SimplifiedPodcast } from '../stores/class/general/podcast';
1
+ import { Podcast, PodcastProcessingStatus, PodcastType, SimplifiedPodcast } from '../stores/class/general/podcast';
2
2
  import { ListClassicReturn } from '../stores/class/general/listReturn';
3
3
  import { useAuthStore } from '../stores/AuthStore';
4
4
  import classicApi from './classicApi';
@@ -17,7 +17,9 @@ export enum PodcastSort {
17
17
  POPULARITY = 'POPULARITY',
18
18
  SCORE = 'SCORE',
19
19
  UPDATE_ASC = 'UPDATE_ASC',
20
- UPDATE_DESC = 'UPDATE_DESC'
20
+ UPDATE_DESC = 'UPDATE_DESC',
21
+ /** Smart sort using seasons settings */
22
+ SEASONAL = 'SEASONAL'
21
23
  }
22
24
 
23
25
  export enum PodcastMonetisation {
@@ -68,11 +70,15 @@ export interface PodcastSearchOptions extends Paginable<PodcastSort> {
68
70
  tags?: Array<string>;
69
71
  /** Filter by beneficiaries/rights holder reference */
70
72
  beneficiaries?: Array<string>;
73
+ /** Filter by seasons */
74
+ season?: Array<number>;
75
+ /** Filter by season episode number */
76
+ seasonEpisode?: Array<number>;
77
+ /** Filter by episode type */
78
+ episodeType?: PodcastType;
71
79
  }
72
80
 
73
81
  async function downloadRegister(podcastId: number, parameters?: Record<string,unknown>): Promise<{ location: string; downloadId: number }> {
74
- const authStore = useAuthStore();
75
-
76
82
  return classicApi.fetchData<{
77
83
  location: string;
78
84
  downloadId: number;
@@ -137,7 +143,7 @@ function processSearchParameters(search: PodcastSearchOptions): FetchParam {
137
143
  * @param adaptParameters If true, some adjustments will be made to the parameters
138
144
  * @return A list of simplified podcasts
139
145
  */
140
- function search(options: PodcastSearchOptions, adaptParameters?: boolean): Promise<ListClassicReturn<SimplifiedPodcast>> {
146
+ async function search(options: PodcastSearchOptions, adaptParameters?: boolean): Promise<ListClassicReturn<SimplifiedPodcast>> {
141
147
  return classicApi.fetchData<ListClassicReturn<SimplifiedPodcast>>({
142
148
  api: ModuleApi.DEFAULT,
143
149
  path: 'v2/podcast/search',
@@ -146,6 +152,21 @@ function search(options: PodcastSearchOptions, adaptParameters?: boolean): Promi
146
152
  });
147
153
  }
148
154
 
155
+ /**
156
+ * Count podcasts matching the filters
157
+ * @param options The search criterias
158
+ * @param adaptParameters If true, some adjustments will be made to the parameters
159
+ * @return A list of simplified podcasts
160
+ */
161
+ async function count(options: PodcastSearchOptions, adaptParameters?: boolean): Promise<number> {
162
+ const result = await search({
163
+ ...options,
164
+ size: 0
165
+ }, adaptParameters);
166
+
167
+ return result.count;
168
+ }
169
+
149
170
  /**
150
171
  * Retrieve the podcasts matching the search criterias, with all their data.
151
172
  * This query is longer, because it also needs to retrieve organisations &
@@ -185,6 +206,7 @@ async function searchFull(options:PodcastSearchOptions, adaptParameters?: boolea
185
206
  }
186
207
 
187
208
  export const podcastApi = {
209
+ count,
188
210
  downloadRegister,
189
211
  get,
190
212
  search,
@@ -150,7 +150,7 @@ export const useRights = () => {
150
150
  }
151
151
 
152
152
  function canEditTranscript(): boolean {
153
- return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION');
153
+ return roleContainsAny('ADMIN', 'ORGANISATION', 'PRODUCTION', 'RESTRICTED_PRODUCTION');
154
154
  }
155
155
 
156
156
  function canSeeHistory(): boolean {
@@ -0,0 +1,43 @@
1
+ import { Podcast } from "@/stores/class/general/podcast";
2
+ import { Emission, SeasonMode } from "../../stores/class/general/emission";
3
+
4
+ /**
5
+ * Composable to facilitate seasons operations
6
+ */
7
+ export const useSeasonsManagement = () => {
8
+ /**
9
+ * Indicates that seasons are enabled on the given emission
10
+ * @param emission The emission to check for seasons
11
+ * @returns True if seasons are enabled, false otherwise
12
+ */
13
+ function areSeasonsEnabled(emission: Emission): boolean {
14
+ return [
15
+ SeasonMode.SEASON_WITHOUT_PODCAST_NUMBERING,
16
+ SeasonMode.SEASON_WITH_PODCAST_NUMBERING
17
+ ].includes(emission.seasonMode);
18
+ }
19
+
20
+ /**
21
+ * Simple formatter to display season/episode of the given podcast
22
+ * @param podcast The podcast to check
23
+ * @returns A string describing the season/episode of the podcast or null
24
+ * if no seasons are defined
25
+ */
26
+ function formatSeason(podcast: Podcast): string|null {
27
+ switch (podcast.emission.seasonMode) {
28
+ case SeasonMode.NO_SEASON:
29
+ return null;
30
+
31
+ case SeasonMode.SEASON_WITHOUT_PODCAST_NUMBERING:
32
+ return `S${podcast.seasonNumber}`;
33
+
34
+ case SeasonMode.SEASON_WITH_PODCAST_NUMBERING:
35
+ return `S${podcast.seasonNumber}·E${podcast.seasonEpisodeNumber}`;
36
+ }
37
+ }
38
+
39
+ return {
40
+ areSeasonsEnabled,
41
+ formatSeason
42
+ }
43
+ }
@@ -18,7 +18,9 @@
18
18
  :label="t('Search')"
19
19
  />
20
20
  </div>
21
+
21
22
  <PodcastList
23
+ v-if="!showSeasons"
22
24
  :first="dfirst"
23
25
  :size="dsize"
24
26
  :iab-id="iabId"
@@ -34,6 +36,36 @@
34
36
  :force-update-parameters="forceUpdateParameters"
35
37
  @fetch="fetch"
36
38
  />
39
+ <ClassicNav
40
+ v-else
41
+ v-model:active-tab="activeSeasonTab"
42
+ :tab-number="seasons.length"
43
+ >
44
+ <template v-for="season in seasons" #[tabNameSlot(season)]>
45
+ {{ $t('Podcast - Season N', { season }) }}
46
+ </template>
47
+
48
+ <template v-for="season in seasons" #[tabContentSlot(season)] :key="season">
49
+ <PodcastList
50
+ class="flex-grow-1"
51
+ :first="dfirst"
52
+ :size="dsize"
53
+ :iab-id="iabId"
54
+ :query="query"
55
+ :participant-id="participantId"
56
+ :emission-id="emissionId"
57
+ :organisation-id="productorId"
58
+ :sort-criteria="sort"
59
+ :reload="reloadList"
60
+ :include-hidden="editRight"
61
+ :show-count="showCount"
62
+ :display-sort-text="false"
63
+ :force-update-parameters="forceUpdateParameters"
64
+ :seasons="[season]"
65
+ @fetch="fetch($event, season)"
66
+ />
67
+ </template>
68
+ </ClassicNav>
37
69
  </section>
38
70
  </template>
39
71
 
@@ -44,28 +76,43 @@ import { Category } from "@/stores/class/general/category";
44
76
  import { defineAsyncComponent, ref, Ref, computed, watch } from "vue";
45
77
  import { Podcast } from "@/stores/class/general/podcast";
46
78
  import { useI18n } from "vue-i18n";
79
+ import ClassicNav from "../../misc/ClassicNav.vue";
80
+ import { Emission } from "@/stores/class/general/emission";
81
+ import { useSeasonsManagement } from "../../composable/useSeasonsManagement";
82
+ import { PodcastSort } from "../../../api/podcastApi";
47
83
  const CategoryChooser = defineAsyncComponent(
48
84
  () => import("../categories/CategoryChooser.vue"),
49
85
  );
50
86
 
51
87
  //Props
52
- const props = defineProps({
53
- first: { default: 0, type: Number },
54
- size: { default: 30, type: Number },
55
- query: { default: undefined, type: String },
56
- participantId: { default: undefined, type: Number },
57
- name: { default: undefined, type: String },
58
- emissionId: { default: undefined, type: Number },
59
- categoryFilter: { default: false, type: Boolean },
60
- reload: { default: false, type: Boolean },
61
- editRight: { default: false, type: Boolean },
62
- productorId: { default: () => [], type: Array as () => Array<string> },
63
- showCount: { default: false, type: Boolean },
64
- forceUpdateParameters: { default: false, type: Boolean },
65
- })
88
+ const props = withDefaults(defineProps<{
89
+ first?: number;
90
+ size?: number;
91
+ query?: string;
92
+ participantId?: number;
93
+ name?: string;
94
+ emissionId?: number;
95
+ categoryFilter?: boolean;
96
+ reload?: boolean;
97
+ editRight?: boolean;
98
+ productorId?: Array<string>;
99
+ showCount?: boolean;
100
+ forceUpdateParameters?: boolean;
101
+ /**
102
+ * Emission for which to display podcasts
103
+ * If set, will check for seasons
104
+ */
105
+ emission?: Emission;
106
+ }>(), {
107
+ first: 0,
108
+ size: 30
109
+ });
66
110
 
67
111
  //Emits
68
- const emit = defineEmits(["fetch", "update:query"]);
112
+ const emit = defineEmits<{
113
+ (e: "fetch", podcasts: Array<Podcast>, season: number|undefined): void;
114
+ (e: "update:query", query: string): void;
115
+ }>();
69
116
 
70
117
  //Data
71
118
  const dfirst = ref(props.first);
@@ -76,6 +123,7 @@ const iabId : Ref<number | undefined>= ref(undefined);
76
123
 
77
124
  //Composables
78
125
  const { t } = useI18n();
126
+ const { areSeasonsEnabled } = useSeasonsManagement();
79
127
 
80
128
  //Computed
81
129
  const titleFilter = computed(() => {
@@ -84,7 +132,32 @@ const titleFilter = computed(() => {
84
132
  : t("All podcast emission button");
85
133
  });
86
134
  const query = computed(() => searchPattern.value.length > 3 ? searchPattern.value : "");
87
- const sort = computed(() => !query.value.length ? "DATE" : "SCORE");
135
+
136
+ const showSeasons = computed(() => {
137
+ return props.emission !== undefined && areSeasonsEnabled(props.emission) && props.emission.seasonCount > 0;
138
+ });
139
+
140
+ const activeSeasonTab = ref(showSeasons.value ? (props.emission?.seasonCount ?? 1) - 1 : 0);
141
+
142
+ const sort = computed((): PodcastSort => {
143
+ if(showSeasons.value === true) {
144
+ return PodcastSort.SEASONAL;
145
+ } else if(!query.value.length) {
146
+ return PodcastSort.DATE;
147
+ } else {
148
+ return PodcastSort.SCORE;
149
+ }
150
+ });
151
+
152
+ const seasons = computed((): Array<number> => {
153
+ const ary: Array<number> = [];
154
+ if (showSeasons.value === true) {
155
+ for (let i = 1; i <= props.emission.seasonCount; i++) {
156
+ ary.push(i);
157
+ }
158
+ }
159
+ return ary;
160
+ });
88
161
 
89
162
  //Watch
90
163
  watch(()=>props.reload, () => {
@@ -98,7 +171,16 @@ watch(searchPattern, () => {
98
171
  function onCategorySelected(category: Category | undefined): void {
99
172
  iabId.value = category?.id ? category.id : undefined;
100
173
  }
101
- function fetch(podcasts: Array<Podcast>): void {
102
- emit("fetch", podcasts);
174
+ function fetch(podcasts: Array<Podcast>, season?: number): void {
175
+ emit("fetch", podcasts, season);
176
+ }
177
+
178
+ /** Name of the slot for the tab's title */
179
+ function tabNameSlot(season: number): string {
180
+ return `${season - 1}`;
181
+ }
182
+ /** Name of the slot for the tab's content */
183
+ function tabContentSlot(season: number): string {
184
+ return `tab${season - 1}`;
103
185
  }
104
186
  </script>
@@ -5,8 +5,9 @@
5
5
  {{ title }}
6
6
  </component>
7
7
  </div>
8
+
8
9
  <div v-if="!podcastId" class="d-flex justify-content-between">
9
- <div class="d-flex">
10
+ <div v-if="!noSort" class="d-flex">
10
11
  <button
11
12
  class="btn btn-underline"
12
13
  :class="{ active: !popularSort }"
@@ -82,6 +83,8 @@ const props = defineProps({
82
83
  noRubriquageId: { default: () => [], type: Array as () => Array<number> },
83
84
  podcastId: { default: undefined, type: Number },
84
85
  titleTag: { default: "h2", type: String },
86
+ /** Hide sort options */
87
+ noSort: { default: false, type: Boolean }
85
88
  })
86
89
 
87
90
  //Emits
@@ -83,6 +83,7 @@ const props = withDefaults(defineProps<{
83
83
  includeHidden?: boolean;
84
84
  showCount?: boolean;
85
85
  displaySortText?: boolean;
86
+ /** Criteria to sort on */
86
87
  sortCriteria?: PodcastSort;
87
88
  validity?: 'true'|'false'|''|boolean; // TODO improve this
88
89
  rubriqueId?: Array<number>;
@@ -96,6 +97,8 @@ const props = withDefaults(defineProps<{
96
97
  beneficiaries?: Array<string>;
97
98
  /** The emission groups to filter on */
98
99
  emissionGroups?: Array<EmissionGroup>;
100
+ /** The seasons to filter on */
101
+ seasons?: Array<number>;
99
102
  }>(), {
100
103
  first: 0,
101
104
  size: 30,
@@ -223,7 +226,8 @@ async function fetchContent(reset: boolean): Promise<void> {
223
226
  processingStatus: [PodcastProcessingStatus.Ready, PodcastProcessingStatus.Processing],
224
227
  withVideo: props.withVideo,
225
228
  tags: props.includeTag?.length ? props.includeTag : undefined,
226
- beneficiaries: props.beneficiaries ?? undefined
229
+ beneficiaries: props.beneficiaries ?? undefined,
230
+ season: props.seasons
227
231
  };
228
232
 
229
233
  try {
@@ -88,6 +88,12 @@
88
88
  {{ podcast.organisation.name }}
89
89
  </router-link>
90
90
  </div>
91
+ <div v-if="showSeasonNumber" class="mb-1">
92
+ {{ `${t('Podcast - Season')} : ${podcast.seasonNumber}` }}
93
+ </div>
94
+ <div v-if="showSeasonEpisodeNumber" class="mb-1">
95
+ {{ `${t('Podcast - Episode number')} : ${podcast.seasonEpisodeNumber}` }}
96
+ </div>
91
97
  <div v-if="'' !== photoCredit" class="mb-1">
92
98
  {{ t("Photo credits") + " : " + photoCredit }}
93
99
  </div>
@@ -161,8 +167,12 @@ import { state } from "../../../stores/ParamSdkStore";
161
167
  import { useAuthStore } from "../../../stores/AuthStore";
162
168
  import displayHelper from "../../../helper/displayHelper";
163
169
  import {usePodcastView} from "../../composable/podcasts/usePodcastView";
164
- import { Podcast } from "@/stores/class/general/podcast";
165
- import { Conference } from "@/stores/class/conference/conference";
170
+ import { Podcast } from "../../../stores/class/general/podcast";
171
+ import { Conference } from "../../../stores/class/conference/conference";
172
+ import { useI18n } from "vue-i18n";
173
+ import { useRouter } from "vue-router";
174
+ import { useSeasonsManagement } from "../../composable/useSeasonsManagement";
175
+ import { SeasonMode } from "../../../stores/class/general/emission";
166
176
 
167
177
  import { defineAsyncComponent, toRefs, computed } from "vue";
168
178
  const ErrorMessage = defineAsyncComponent(
@@ -193,8 +203,6 @@ const Countdown = defineAsyncComponent(() => import("../live/CountDown.vue"));
193
203
  const TagList = defineAsyncComponent(() => import("./TagList.vue"));
194
204
  const ShareAnonymous = defineAsyncComponent(() => import("../sharing/ShareAnonymous.vue"));
195
205
  const PodcastRubriqueList = defineAsyncComponent(() => import("./PodcastRubriqueList.vue"));
196
- import { useI18n } from "vue-i18n";
197
- import { useRouter } from "vue-router";
198
206
 
199
207
  //Props
200
208
  const props = defineProps<{
@@ -222,6 +230,7 @@ const {
222
230
  } = usePodcastView(propsRef.podcast, propsRef.podcastConference);
223
231
  const authStore = useAuthStore();
224
232
  const router = useRouter();
233
+ const { areSeasonsEnabled } = useSeasonsManagement();
225
234
 
226
235
  //Computed
227
236
  const podcastRubriques = computed(() => {
@@ -279,6 +288,14 @@ const tags = computed(() => {
279
288
  return tags;
280
289
  });
281
290
 
291
+ const showSeasonNumber = computed((): boolean => {
292
+ return areSeasonsEnabled(props.podcast.emission) && props.podcast.seasonNumber !== undefined;
293
+ });
294
+
295
+ const showSeasonEpisodeNumber = computed((): boolean => {
296
+ return props.podcast.emission.seasonMode === SeasonMode.SEASON_WITH_PODCAST_NUMBERING && props.podcast.seasonEpisodeNumber !== undefined;
297
+ });
298
+
282
299
  //Methods
283
300
  function formatCredits(credits: string|undefined): string {
284
301
  if (credits === undefined) {
@@ -11,6 +11,7 @@
11
11
  :rubrique-id="rubriqueId"
12
12
  :no-rubriquage-id="noRubriquageId"
13
13
  :title-tag="titleTag"
14
+ :no-sort="noSort"
14
15
  @sort-chrono="sortChrono"
15
16
  @sort-popular="sortPopular"
16
17
  >
@@ -59,10 +60,14 @@ const props = defineProps({
59
60
  rubriquageId: { default: () => [], type: Array as () => Array<number> },
60
61
  noRubriquageId: { default: () => [], type: Array as () => Array<number> },
61
62
  query: { default: undefined, type: String },
63
+ /** Filter on season */
64
+ season: { default: undefined, type: Number },
62
65
  lastThreeMonths: { default: false, type: Boolean },
63
66
  titleTag: { default: "h2", type: String },
64
67
  /** The podcast from which suggestions are made */
65
- podcastId: { type: Number }
68
+ podcastId: { type: Number },
69
+ /** Hide sort options */
70
+ noSort: { default: false, type: Boolean }
66
71
  })
67
72
 
68
73
  //Emits
@@ -104,6 +109,7 @@ onBeforeMount(()=>{
104
109
 
105
110
  //Methods
106
111
  async function fetchNext(): Promise<void> {
112
+ // TODO use podcastApi
107
113
  const data = await classicApi.fetchData<ListClassicReturn<Podcast>>({
108
114
  api: 0,
109
115
  path: "podcast/search",
@@ -123,10 +129,10 @@ async function fetchNext(): Promise<void> {
123
129
  sort: popularSort.value ? "POPULARITY" : "DATE",
124
130
  query: props.query,
125
131
  includeStatus: ["READY", "PROCESSING"],
126
- after:
127
- popularSort.value && props.lastThreeMonths
132
+ after: popularSort.value && props.lastThreeMonths
128
133
  ? dayjs().subtract(3, "months").toISOString()
129
134
  : undefined,
135
+ season: props.season
130
136
  },
131
137
  specialTreatement: true,
132
138
  });
@@ -10,12 +10,12 @@
10
10
  `selected` : true if the option is selected
11
11
  -->
12
12
  <template>
13
- <div role="radiogroup" class="d-flex" :class="isColumn ? 'flex-column' : ''">
13
+ <div role="radiogroup" class="d-flex" :class="isColumn !== false ? 'flex-column' : ''">
14
14
  <div
15
15
  v-for="option in options"
16
16
  :key="option.title"
17
17
  class="octopus-form-item"
18
- :class="isColumn ? 'd-flex flex-nowrap align-items-center' : 'me-2'"
18
+ :class="isColumn !== false ? 'd-flex flex-nowrap align-items-center' : 'me-2'"
19
19
  >
20
20
  <input
21
21
  :id="idRadio + option.value"
@@ -37,22 +37,21 @@
37
37
 
38
38
  <script setup generic="T extends { title: string; value: string|undefined; }" lang="ts">
39
39
  //Props
40
- const { textInit } = defineProps({
41
- idRadio: { default: "", type: String },
42
- isDisabled: { default: false, type: Boolean },
43
- options: {
44
- default: () => [],
45
- type: Array as () => Array<T>,
46
- },
47
- textInit: { default: undefined, type: String },
48
- isColumn: { default: true, type: Boolean },
49
- })
40
+ const { textInit, isColumn = true } = defineProps<{
41
+ options: Array<T>;
42
+ textInit?: string;
43
+ idRadio?: string;
44
+ isDisabled?: boolean;
45
+ isColumn?: boolean;
46
+ }>();
50
47
 
51
48
  //Emits
52
- const emit = defineEmits(["update:textInit"]);
49
+ const emit = defineEmits<{
50
+ (e: 'update:textInit', value: string): void;
51
+ }>();
53
52
 
54
53
  //Methods
55
- function onChange(value:string){
54
+ function onChange(value: string){
56
55
  emit('update:textInit', value)
57
56
  }
58
57
 
@@ -14,7 +14,13 @@
14
14
  :options="options?.topBarMainContent"
15
15
  />
16
16
  </header>
17
- <div v-if="generalStore.contentToDisplay" class="header-content-bg" :style="headerBackgroundImage" :class="{ scrolled: scrolled, 'header-force-blur':needToBlur }" >
17
+
18
+ <div
19
+ v-if="generalStore.contentToDisplay"
20
+ class="header-content-bg"
21
+ :style="headerBackgroundImage"
22
+ :class="{ scrolled: scrolled, 'header-force-blur':needToBlur }"
23
+ >
18
24
  <div class="header-additional-content header-content">
19
25
  <h1 v-if="!scrolled" class="text-truncate">
20
26
  {{ titleToDisplay }}
@@ -54,7 +54,7 @@
54
54
  :just-buttons="true"
55
55
  />
56
56
  <div class="ms-2 fw-bold">
57
- {{ t("Listen to the latest episode") }}
57
+ {{ messageListenEpisode }}
58
58
  </div>
59
59
  </div>
60
60
 
@@ -99,6 +99,7 @@
99
99
  :size="ps"
100
100
  :show-count="true"
101
101
  :emission-id="emissionId"
102
+ :emission="emission"
102
103
  :category-filter="false"
103
104
  :edit-right="editRight"
104
105
  :productor-id="[emission.orga.id]"
@@ -138,6 +139,7 @@ import { useSimplePageParam } from "../composable/route/useSimplePageParam";
138
139
  import ErrorMessage from "../misc/ErrorMessage.vue";
139
140
  import ClassicHelpButton from "../misc/ClassicHelpButton.vue";
140
141
  import { emissionApi } from "../../api/emissionApi";
142
+ import { useSeasonsManagement } from "../composable/useSeasonsManagement";
141
143
 
142
144
  const ShareAnonymous = defineAsyncComponent(() => import("../display/sharing/ShareAnonymous.vue"));
143
145
  const PodcastFilterList = defineAsyncComponent(
@@ -193,15 +195,16 @@ const { t } = useI18n();
193
195
  const { useProxyImageUrl } = useImageProxy();
194
196
  const { isPodcastmaker, isEditRights, authOrgaId } = useOrgaComputed();
195
197
  const { updatePathParams } = useSeoTitleUrl();
196
- const {handle403} = useErrorHandler();
198
+ const { handle403 } = useErrorHandler();
197
199
  const filterStore = useFilterStore();
198
- const generalStore= useGeneralStore();
199
- const route= useRoute();
200
+ const generalStore = useGeneralStore();
201
+ const route = useRoute();
200
202
  const {
201
203
  searchPattern,
202
204
  paginateFirst,
203
205
  isInit
204
206
  } = useSimplePageParam(props, true);
207
+ const { areSeasonsEnabled, formatSeason } = useSeasonsManagement();
205
208
 
206
209
 
207
210
  //Computed
@@ -209,9 +212,17 @@ const name = computed(() => emission.value?.name ?? "");
209
212
  const description = computed(() => emission.value?.description ?? "");
210
213
  const editRight = computed(() => isEditRights(emission.value?.orga.id));
211
214
 
215
+ const messageListenEpisode = computed((): string => {
216
+ const base = t("Listen to the latest episode");
217
+ if (lastPodcast.value !== undefined && areSeasonsEnabled(emission.value)) {
218
+ return base + ` (${formatSeason(lastPodcast.value)})`;
219
+ } else {
220
+ return base;
221
+ }
222
+ });
212
223
 
213
224
  //Watch
214
- watch(()=>props.emissionId, () => {getEmissionDetails()}, {immediate: true});
225
+ watch(() => props.emissionId, getEmissionDetails, { immediate: true });
215
226
 
216
227
 
217
228
  onBeforeUnmount(() => {
@@ -248,12 +259,13 @@ async function getEmissionDetails(): Promise<void> {
248
259
  initError();
249
260
  }
250
261
  }
251
- function podcastsFetched(podcasts: Array<Podcast>) {
262
+ function podcastsFetched(podcasts: Array<Podcast>, season: number|undefined) {
263
+ if (season !== undefined && season !== emission.value?.seasonCount) {
264
+ return;
265
+ }
266
+
252
267
  for (const podcast of podcasts) {
253
- if (
254
- "READY" === podcast.processingStatus &&
255
- podcast.availability.visibility
256
- ) {
268
+ if ("READY" === podcast.processingStatus && podcast.availability.visibility) {
257
269
  lastPodcast.value = podcast;
258
270
  return;
259
271
  }
@@ -417,5 +417,11 @@
417
417
  "Podcast": "Folge",
418
418
  "Player - Transcription - AI Warning": "Die Transkription basiert auf KI und kann Fehler enthalten, bitte teilen Sie uns dies mit.",
419
419
  "All organisations": "Alle Organisationen",
420
- "Emission subtitle": "Untertitel der Sendung"
420
+ "Emission subtitle": "Untertitel der Sendung",
421
+ "Podcast type - Full": "Standard",
422
+ "Podcast type - Trailer": "Anhänger",
423
+ "Podcast type - Bonus": "Boni",
424
+ "Podcast - Season N": "Saison {season}",
425
+ "Podcast - Season": "Jahreszeit",
426
+ "Podcast - Episode number": "Episodennummer"
421
427
  }
@@ -417,5 +417,11 @@
417
417
  "Podcast": "Episode",
418
418
  "Player - Transcription - AI Warning": "The transcription is based on AI and may contain errors, please let us know.",
419
419
  "All organisations": "All organizations",
420
- "Emission subtitle": "Subtitle of the show"
420
+ "Emission subtitle": "Subtitle of the show",
421
+ "Podcast type - Full": "Standard",
422
+ "Podcast type - Trailer": "Trailer",
423
+ "Podcast type - Bonus": "Bonuses",
424
+ "Podcast - Season N": "Saison {season}",
425
+ "Podcast - Season": "Season",
426
+ "Podcast - Episode number": "Episode number"
421
427
  }
@@ -417,5 +417,11 @@
417
417
  "Podcast": "Episodio",
418
418
  "Player - Transcription - AI Warning": "La transcripción se basa en IA y puede contener errores, háganoslo saber.",
419
419
  "All organisations": "Todas las organizaciones",
420
- "Emission subtitle": "Subtítulo del programa"
420
+ "Emission subtitle": "Subtítulo del programa",
421
+ "Podcast type - Full": "Estándar",
422
+ "Podcast type - Trailer": "Tráiler",
423
+ "Podcast type - Bonus": "Bonificaciones",
424
+ "Podcast - Season N": "Temporada {season}",
425
+ "Podcast - Season": "Estación",
426
+ "Podcast - Episode number": "Número de episodio"
421
427
  }
@@ -440,5 +440,11 @@
440
440
  "SmartLink - To emission on podcastmaker": "Page officielle de l'émission - {organisation}",
441
441
  "SmartLink - To playlist on podcastmaker": "Page officielle de la playlist - {organisation}",
442
442
  "Miniplayer - Parameters - Auto height": "Hauteur automatique",
443
- "Player - Transcription - AI Warning": "La transcription est basée sur une IA et peut comporter des erreurs, n’hésitez pas à nous en informer."
443
+ "Player - Transcription - AI Warning": "La transcription est basée sur une IA et peut comporter des erreurs, n’hésitez pas à nous en informer.",
444
+ "Podcast type - Full": "Standard",
445
+ "Podcast type - Trailer": "Trailer",
446
+ "Podcast type - Bonus": "Bonus",
447
+ "Podcast - Season N": "Saison {season}",
448
+ "Podcast - Season": "Saison",
449
+ "Podcast - Episode number": "Numéro d'épisode"
444
450
  }
@@ -418,5 +418,11 @@
418
418
  "Podcast": "Episodio",
419
419
  "Player - Transcription - AI Warning": "La trascrizione si basa sull'intelligenza artificiale e potrebbe contenere errori, faccelo sapere.",
420
420
  "All organisations": "Tutte le organizzazioni",
421
- "Emission subtitle": "Sottotitolo dello spettacolo"
421
+ "Emission subtitle": "Sottotitolo dello spettacolo",
422
+ "Podcast type - Full": "Standard",
423
+ "Podcast type - Trailer": "Rimorchio",
424
+ "Podcast type - Bonus": "Bonus",
425
+ "Podcast - Season N": "Stagione {season}",
426
+ "Podcast - Season": "Stagione",
427
+ "Podcast - Episode number": "Numero dell'episodio"
422
428
  }
@@ -416,5 +416,11 @@
416
416
  "Podcast": "Epizoda",
417
417
  "Player - Transcription - AI Warning": "Transkripcija temelji na umetni inteligenci in lahko vsebuje napake, zato nas obvestite.",
418
418
  "All organisations": "Vse organizacije",
419
- "Emission subtitle": "Podnaslov oddaje"
419
+ "Emission subtitle": "Podnaslov oddaje",
420
+ "Podcast type - Full": "Standardno",
421
+ "Podcast type - Trailer": "Napovednik",
422
+ "Podcast type - Bonus": "Bonusi",
423
+ "Podcast - Season N": "Sezona {season}",
424
+ "Podcast - Season": "Sezona",
425
+ "Podcast - Episode number": "Številka epizode"
420
426
  }
@@ -4,6 +4,18 @@ import { Person } from "../user/person";
4
4
  import { ItuneCategory } from "./ituneCategory";
5
5
  import { Annotations } from ".";
6
6
 
7
+ /**
8
+ * Season settings for emissions
9
+ */
10
+ export enum SeasonMode {
11
+ /** This emission doesn't have seasons */
12
+ NO_SEASON = 'NO_SEASON',
13
+ /** This emission has seasons and episodes are ordered by date */
14
+ SEASON_WITHOUT_PODCAST_NUMBERING = 'SEASON_WITHOUT_PODCAST_NUMBERING',
15
+ /** This emission has seasons and episodes are ordered manually */
16
+ SEASON_WITH_PODCAST_NUMBERING = 'SEASON_WITH_PODCAST_NUMBERING'
17
+ }
18
+
7
19
  /**
8
20
  * An emission
9
21
  */
@@ -40,6 +52,12 @@ export interface Emission {
40
52
  tags?: string[];
41
53
  /** The ids of groups this emission belongs to */
42
54
  groupIds?: Array<number>
55
+ /** Seasons configuration */
56
+ seasonMode: SeasonMode;
57
+ /** Number of seasons on this emission */
58
+ seasonCount: number;
59
+ /** Indicates that the emission has explicit content */
60
+ explicit?: boolean;
43
61
  }
44
62
 
45
63
  export function emptyEmissionData(orga?: Organisation): Emission {
@@ -54,5 +72,7 @@ export function emptyEmissionData(orga?: Organisation): Emission {
54
72
  rubriqueIds: [],
55
73
  monetisable: "UNDEFINED",
56
74
  limits: {},
75
+ seasonMode: SeasonMode.NO_SEASON,
76
+ seasonCount: 0
57
77
  };
58
78
  }
@@ -17,6 +17,13 @@ export enum PodcastProcessingStatus {
17
17
  All = "ALL"
18
18
  }
19
19
 
20
+ /** Type of episode */
21
+ export enum PodcastType {
22
+ FULL = 'full',
23
+ TRAILER = 'trailer',
24
+ BONUS = 'bonus'
25
+ }
26
+
20
27
  /** Describe the availability of the podcast */
21
28
  export interface PodcastAvailability {
22
29
  date?: number | null;
@@ -46,6 +53,8 @@ export interface Podcast {
46
53
  availability: PodcastAvailability;
47
54
  /** Description of the podcast */
48
55
  description?: string;
56
+ /** Optional summary of the podcast */
57
+ summary?: string;
49
58
  /** Publishing date */
50
59
  pubDate?: string;
51
60
  /** The status of the processing of the audio file */
@@ -56,6 +65,14 @@ export interface Podcast {
56
65
  ofTags?: Array<string>;
57
66
  /** List of beneficiaries/rights holders */
58
67
  beneficiaries: Array<string>;
68
+ /** Indicates that the episode has explicit content */
69
+ explicit?: boolean;
70
+ /** Type of episode */
71
+ seasonEpisodeType: PodcastType;
72
+ /** Number of season in which podcast belongs */
73
+ seasonNumber?: number;
74
+ /** Order of the episode in the season */
75
+ seasonEpisodeNumber?: number;
59
76
 
60
77
  createdAt?: string;
61
78
  createdByUserId?: string;
@@ -47,6 +47,13 @@ main, #app{
47
47
  font-family: var(--octopus-font-family);
48
48
  }
49
49
 
50
+ // Display an asterisk for required fields
51
+ label.required:after {
52
+ content: "*";
53
+ font-weight: bold;
54
+ margin-left: 3px;
55
+ }
56
+
50
57
  /** Link style */
51
58
  a{
52
59
  word-break: break-word;
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('../../src/api/classicApi', () => ({
4
+ default: {
5
+ fetchData: vi.fn()
6
+ }
7
+ }));
8
+
9
+ import classicApi from '../../src/api/classicApi';
10
+ import { podcastApi } from '../../src/api/podcastApi';
11
+
12
+ const baseOptions = { organisationId: ['org-1'] };
13
+
14
+ describe('podcastApi', () => {
15
+ describe('count', () => {
16
+ beforeEach(() => {
17
+ vi.mocked(classicApi.fetchData).mockResolvedValue({ count: 42, result: [], sort: null });
18
+ });
19
+
20
+ it('returns the count from search results', async () => {
21
+ const result = await podcastApi.count(baseOptions);
22
+ expect(result).toBe(42);
23
+ });
24
+
25
+ it('calls search with size 0', async () => {
26
+ await podcastApi.count(baseOptions);
27
+ expect(classicApi.fetchData).toHaveBeenCalledWith(
28
+ expect.objectContaining({
29
+ parameters: expect.objectContaining({ size: 0 })
30
+ })
31
+ );
32
+ });
33
+
34
+ it('forwards other options to search', async () => {
35
+ await podcastApi.count({ ...baseOptions, emissionId: 5 });
36
+ expect(classicApi.fetchData).toHaveBeenCalledWith(
37
+ expect.objectContaining({
38
+ parameters: expect.objectContaining({ organisationId: ['org-1'], emissionId: 5 })
39
+ })
40
+ );
41
+ });
42
+ });
43
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { useSeasonsManagement } from '@/components/composable/useSeasonsManagement';
3
+ import { emptyEmissionData, SeasonMode } from '@/stores/class/general/emission';
4
+ import { emptyPodcastData } from '@/stores/class/general/podcast';
5
+
6
+ const { areSeasonsEnabled, formatSeason } = useSeasonsManagement();
7
+
8
+ describe('useSeasonsManagement', () => {
9
+ describe('areSeasonsEnabled', () => {
10
+ it.each([
11
+ SeasonMode.SEASON_WITH_PODCAST_NUMBERING,
12
+ SeasonMode.SEASON_WITHOUT_PODCAST_NUMBERING,
13
+ ])('returns true for %s', (seasonMode) => {
14
+ expect(areSeasonsEnabled({ ...emptyEmissionData(), seasonMode })).toBe(true);
15
+ });
16
+
17
+ it('returns false for NO_SEASON', () => {
18
+ expect(areSeasonsEnabled(emptyEmissionData())).toBe(false);
19
+ });
20
+ });
21
+
22
+ describe('formatSeason', () => {
23
+ it.each([
24
+ { seasonMode: SeasonMode.NO_SEASON, seasonNumber: 1, seasonEpisodeNumber: 1, expected: null },
25
+ { seasonMode: SeasonMode.SEASON_WITHOUT_PODCAST_NUMBERING, seasonNumber: 2, seasonEpisodeNumber: 1, expected: 'S2' },
26
+ { seasonMode: SeasonMode.SEASON_WITH_PODCAST_NUMBERING, seasonNumber: 2, seasonEpisodeNumber: 5, expected: 'S2·E5' },
27
+ ])('formats $seasonMode as $expected', ({ seasonMode, seasonNumber, seasonEpisodeNumber, expected }) => {
28
+ const podcast = emptyPodcastData();
29
+ podcast.seasonNumber = seasonNumber;
30
+ podcast.seasonEpisodeNumber = seasonEpisodeNumber;
31
+ podcast.emission.seasonMode = seasonMode;
32
+ expect(formatSeason(podcast)).toBe(expected);
33
+ });
34
+ });
35
+ });
@@ -0,0 +1,33 @@
1
+ import '@tests/mocks/i18n';
2
+
3
+ import PodcastFilterList from '@/components/display/podcasts/PodcastFilterList.vue';
4
+ import { emptyEmissionData, SeasonMode } from '@/stores/class/general/emission';
5
+ import { mount as testMount } from '@tests/utils';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ const mount = (props: Record<string, unknown> = {}) =>
9
+ testMount(PodcastFilterList, { shallow: true, props });
10
+
11
+ describe('PodcastFilterList', () => {
12
+ describe('season display', () => {
13
+ it.each([
14
+ { desc: 'no emission provided', props: {} },
15
+ { desc: 'emission has NO_SEASON mode', props: { emission: { ...emptyEmissionData(), seasonMode: SeasonMode.NO_SEASON, seasonCount: 3 } } },
16
+ { desc: 'emission has seasons but seasonCount is 0', props: { emission: { ...emptyEmissionData(), seasonMode: SeasonMode.SEASON_WITH_PODCAST_NUMBERING, seasonCount: 0 } } },
17
+ ])('shows a plain list when $desc', async ({ props }) => {
18
+ const wrapper = await mount(props);
19
+ expect(wrapper.find('podcast-list-stub').exists()).toBe(true);
20
+ expect(wrapper.find('classic-nav-stub').exists()).toBe(false);
21
+ });
22
+
23
+ it.each([
24
+ SeasonMode.SEASON_WITH_PODCAST_NUMBERING,
25
+ SeasonMode.SEASON_WITHOUT_PODCAST_NUMBERING,
26
+ ])('shows a season nav when seasonMode is %s', async (seasonMode) => {
27
+ const emission = { ...emptyEmissionData(), seasonMode, seasonCount: 3 };
28
+ const wrapper = await mount({ emission });
29
+ expect(wrapper.find('classic-nav-stub').exists()).toBe(true);
30
+ expect(wrapper.find('podcast-list-stub').exists()).toBe(false);
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,23 @@
1
+ import '@tests/mocks/i18n';
2
+ import '@tests/mocks/useRouter';
3
+
4
+ import PodcastInlineListTemplate from '@/components/display/podcasts/PodcastInlineListTemplate.vue';
5
+ import { mount as testMount } from '@tests/utils';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ const mount = (props: Record<string, unknown> = {}) =>
9
+ testMount(PodcastInlineListTemplate, { shallow: true, props });
10
+
11
+ describe('PodcastInlineListTemplate', () => {
12
+ describe('noSort prop', () => {
13
+ it('shows sort buttons by default', async () => {
14
+ const wrapper = await mount();
15
+ expect(wrapper.find('.btn-underline').exists()).toBe(true);
16
+ });
17
+
18
+ it('hides sort buttons when noSort is true', async () => {
19
+ const wrapper = await mount({ noSort: true });
20
+ expect(wrapper.find('.btn-underline').exists()).toBe(false);
21
+ });
22
+ });
23
+ });
@@ -2,47 +2,74 @@ import '@tests/mocks/i18n';
2
2
  import '@tests/mocks/useRouter';
3
3
 
4
4
  import PodcastModuleBox from '@/components/display/podcasts/PodcastModuleBox.vue';
5
+ import { SeasonMode } from '@/stores/class/general/emission';
5
6
  import { emptyPodcastData, Podcast } from '@/stores/class/general/podcast';
6
- import { mount, setupAuthStore } from '@tests/utils';
7
+ import { mount as testMount, setupAuthStore } from '@tests/utils';
7
8
  import { describe, expect, it } from 'vitest';
8
9
  import { initialize } from '@/stores/ParamSdkStore';
9
10
 
11
+ const mount = (podcast: Podcast) => testMount(PodcastModuleBox, {
12
+ props: { podcast },
13
+ stubs: ['ShareAnonymous', 'LikeSection'],
14
+ beforeMount: setupAuthStore()
15
+ });
16
+
10
17
  describe('PodcastModuleBox', () => {
11
18
  describe('date display', () => {
12
19
  const podcast: Podcast = emptyPodcastData();
13
20
  podcast.pubDate = '2025-12-01T10:21:31.000+00:00';
14
21
 
15
22
  it('shows the date without time by default', async() => {
16
- const wrapper = await mount(PodcastModuleBox, {
17
- props: { podcast },
18
- stubs: ['ShareAnonymous', 'LikeSection'],
19
- beforeMount: setupAuthStore()
20
- });
23
+ const wrapper = await mount(podcast);
21
24
  expect(wrapper.text()).toContain('1 December 2025');
22
25
  expect(wrapper.text()).not.toContain('11:21');
23
26
  });
24
27
 
25
- it('shows the date without time when disable in SdkParams', async() => {
26
- const wrapper = await mount(PodcastModuleBox, {
27
- props: { podcast },
28
- stubs: ['ShareAnonymous', 'LikeSection'],
29
- beforeMount: setupAuthStore()
30
- });
28
+ it('shows the date with time when enabled in SdkParams', async() => {
29
+ initialize({ generalParameters: { showTimeWithDates: true } });
30
+ const wrapper = await mount(podcast);
31
31
  expect(wrapper.text()).toContain('1 December 2025');
32
- expect(wrapper.text()).not.toContain('11:21');
32
+ expect(wrapper.text()).toContain('11:21');
33
33
  });
34
+ });
34
35
 
35
- it('shows the date with time when enabled in SdkParams', async() => {
36
- initialize({
37
- generalParameters: { showTimeWithDates: true }
36
+ describe('season info', () => {
37
+ function makePodcast(seasonMode = SeasonMode.NO_SEASON, overrides: Partial<Podcast> = {}) {
38
+ const podcast = emptyPodcastData();
39
+ podcast.emission.seasonMode = seasonMode;
40
+ return { ...podcast, ...overrides };
41
+ }
42
+
43
+ describe('season number', () => {
44
+ it('hidden when seasonNumber is not set', async () => {
45
+ const wrapper = await mount(makePodcast(SeasonMode.SEASON_WITH_PODCAST_NUMBERING));
46
+ expect(wrapper.text()).not.toContain('Podcast - Season');
38
47
  });
39
- const wrapper = await mount(PodcastModuleBox, {
40
- props: { podcast },
41
- stubs: ['ShareAnonymous', 'LikeSection'],
42
- beforeMount: setupAuthStore()
48
+
49
+ it('hidden when emission has no season mode', async () => {
50
+ const wrapper = await mount(makePodcast(SeasonMode.NO_SEASON, { seasonNumber: 2 }));
51
+ expect(wrapper.text()).not.toContain('Podcast - Season');
52
+ });
53
+
54
+ it.each([
55
+ SeasonMode.SEASON_WITH_PODCAST_NUMBERING,
56
+ SeasonMode.SEASON_WITHOUT_PODCAST_NUMBERING,
57
+ ])('shown for %s', async (seasonMode) => {
58
+ const wrapper = await mount(makePodcast(seasonMode, { seasonNumber: 2 }));
59
+ expect(wrapper.text()).toContain('Podcast - Season : 2');
60
+ });
61
+ });
62
+
63
+ describe('episode number', () => {
64
+ it('hidden for SEASON_WITHOUT_PODCAST_NUMBERING', async () => {
65
+ const wrapper = await mount(makePodcast(SeasonMode.SEASON_WITHOUT_PODCAST_NUMBERING, { seasonEpisodeNumber: 5 }));
66
+ expect(wrapper.text()).not.toContain('Podcast - Episode number');
67
+ });
68
+
69
+ it('shown for SEASON_WITH_PODCAST_NUMBERING', async () => {
70
+ const wrapper = await mount(makePodcast(SeasonMode.SEASON_WITH_PODCAST_NUMBERING, { seasonEpisodeNumber: 5 }));
71
+ expect(wrapper.text()).toContain('Podcast - Episode number : 5');
43
72
  });
44
- expect(wrapper.text()).toContain('1 December 2025');
45
- expect(wrapper.text()).toContain('11:21');
46
73
  });
47
74
  });
48
75
  });
@@ -0,0 +1,86 @@
1
+ import '@tests/mocks/i18n';
2
+ import '@tests/mocks/useRouter';
3
+
4
+ import EmissionPage from '@/components/pages/EmissionPage.vue';
5
+ import { emptyEmissionData, SeasonMode } from '@/stores/class/general/emission';
6
+ import { emptyPodcastData, PodcastProcessingStatus } from '@/stores/class/general/podcast';
7
+ import { mount, VueWrapper } from '@tests/utils';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import { nextTick } from 'vue';
10
+
11
+ vi.mock('@/api/emissionApi', () => ({
12
+ emissionApi: { get: vi.fn() }
13
+ }));
14
+ vi.mock('@/components/composable/route/useSeoTitleUrl.ts', () => ({
15
+ useSeoTitleUrl: () => ({ updatePathParams: vi.fn() })
16
+ }));
17
+ vi.mock('@/components/composable/useImageProxy', () => ({
18
+ useImageProxy: () => ({ useProxyImageUrl: vi.fn() })
19
+ }));
20
+
21
+ import { emissionApi } from '@/api/emissionApi';
22
+
23
+ const publicOrga = { id: 'org-1', name: 'Test', imageUrl: '', privacy: 'PUBLIC' };
24
+
25
+ function makeEmission(seasonMode: SeasonMode) {
26
+ return { ...emptyEmissionData(), seasonMode, seasonCount: 2, orga: publicOrga };
27
+ }
28
+
29
+ function makeReadyPodcast(seasonMode: SeasonMode = SeasonMode.NO_SEASON) {
30
+ const podcast = emptyPodcastData();
31
+ podcast.processingStatus = PodcastProcessingStatus.Ready;
32
+ podcast.seasonNumber = 1;
33
+ podcast.seasonEpisodeNumber = 3;
34
+ podcast.emission.seasonMode = seasonMode;
35
+ return podcast;
36
+ }
37
+
38
+ async function mountPage(seasonMode: SeasonMode) {
39
+ vi.mocked(emissionApi.get).mockResolvedValue(makeEmission(seasonMode));
40
+ return mount(EmissionPage, { shallow: true, props: { emissionId: 1 } });
41
+ }
42
+
43
+ async function triggerFetch(wrapper: VueWrapper, seasonMode: SeasonMode, season?: number) {
44
+ await wrapper.findComponent({ name: 'PodcastFilterList' }).vm.$emit('fetch', [makeReadyPodcast(seasonMode)], season);
45
+ await nextTick();
46
+ }
47
+
48
+ describe('EmissionPage', () => {
49
+ describe('messageListenEpisode', () => {
50
+ it('does not show season info before any podcast is fetched', async () => {
51
+ const wrapper = await mountPage(SeasonMode.SEASON_WITH_PODCAST_NUMBERING);
52
+ expect(wrapper.text()).not.toContain('S1·E3');
53
+ });
54
+
55
+ it('shows base message without season info for NO_SEASON emission', async () => {
56
+ const wrapper = await mountPage(SeasonMode.NO_SEASON);
57
+ await triggerFetch(wrapper, SeasonMode.NO_SEASON);
58
+ expect(wrapper.text()).toContain('Listen to the latest episode');
59
+ expect(wrapper.text()).not.toContain('(S');
60
+ });
61
+
62
+ it.each([
63
+ { seasonMode: SeasonMode.SEASON_WITH_PODCAST_NUMBERING, expected: 'Listen to the latest episode (S1·E3)' },
64
+ { seasonMode: SeasonMode.SEASON_WITHOUT_PODCAST_NUMBERING, expected: 'Listen to the latest episode (S1)' },
65
+ ])('appends season info for $seasonMode', async ({ seasonMode, expected }) => {
66
+ const wrapper = await mountPage(seasonMode);
67
+ await triggerFetch(wrapper, seasonMode);
68
+ expect(wrapper.text()).toContain(expected);
69
+ });
70
+ });
71
+
72
+ describe('podcastsFetched season filtering', () => {
73
+ // seasonCount is 2 in makeEmission
74
+ it.each([undefined, 2])('shows lastPodcast when season is %s', async (season) => {
75
+ const wrapper = await mountPage(SeasonMode.SEASON_WITH_PODCAST_NUMBERING);
76
+ await triggerFetch(wrapper, SeasonMode.SEASON_WITH_PODCAST_NUMBERING, season);
77
+ expect(wrapper.text()).toContain('Listen to the latest episode');
78
+ });
79
+
80
+ it('ignores podcasts from earlier seasons', async () => {
81
+ const wrapper = await mountPage(SeasonMode.SEASON_WITH_PODCAST_NUMBERING);
82
+ await triggerFetch(wrapper, SeasonMode.SEASON_WITH_PODCAST_NUMBERING, 1);
83
+ expect(wrapper.text()).not.toContain('Listen to the latest episode');
84
+ });
85
+ });
86
+ });