|
55 | 55 | </div> |
56 | 56 |
|
57 | 57 | <div class="max-h-[60vh] overflow-y-auto divide-y divide-gray-100"> |
58 | | - <p v-if="!activeIssues.length" class="px-4 py-8 text-sm text-gray-500 text-center"> |
| 58 | + <p v-if="issuesLoading" class="px-4 py-8 text-sm text-gray-500 text-center"> |
| 59 | + Checking segments… |
| 60 | + </p> |
| 61 | + <p v-else-if="issuesError" class="px-4 py-8 text-sm text-red-600 text-center"> |
| 62 | + Could not validate segments. Please try again. |
| 63 | + </p> |
| 64 | + <p v-else-if="!activeIssues.length" class="px-4 py-8 text-sm text-gray-500 text-center"> |
59 | 65 | No issues found 🎉 |
60 | 66 | </p> |
61 | 67 | <button |
@@ -533,6 +539,8 @@ export default { |
533 | 539 | return { |
534 | 540 | timeStep: 50, |
535 | 541 | showIssues: false, |
| 542 | + issuesLoading: false, |
| 543 | + issuesError: false, |
536 | 544 | issueGroups: [], |
537 | 545 | activeIssueTab: 'current', |
538 | 546 | showCompare: false, |
@@ -853,23 +861,108 @@ export default { |
853 | 861 | redo() { |
854 | 862 | this.$store.commit('REDO_SEGMENTS'); |
855 | 863 | }, |
856 | | - openIssues() { |
857 | | - const groups = [ |
858 | | - { id: 'current', name: 'Current', color: '#198754', issues: this.findSegmentIssues((verse) => this.mainVerseData(verse)) }, |
859 | | - ]; |
| 864 | + async openIssues() { |
| 865 | + this.activeIssueTab = 'current'; |
| 866 | + this.showIssues = true; |
860 | 867 |
|
861 | | - for (const source of this.compareSources) { |
862 | | - groups.push({ |
863 | | - id: source.id, |
864 | | - name: source.name, |
865 | | - color: source.color, |
866 | | - issues: this.findSegmentIssues((verse) => this.sourceVerseData(source, verse)), |
867 | | - }); |
| 868 | + // The gapped (ayah-by-ayah) tool keeps client-side validation across every |
| 869 | + // compare source. The gapless (surah) tool is unified: its authoritative |
| 870 | + // issues come from the server so it runs the exact same rules as the admin |
| 871 | + // model view and the export job. |
| 872 | + if (this.audioType === 'ayah') { |
| 873 | + const groups = [ |
| 874 | + { id: 'current', name: 'Current', color: '#198754', issues: this.findSegmentIssues((verse) => this.mainVerseData(verse)) }, |
| 875 | + ]; |
| 876 | +
|
| 877 | + for (const source of this.compareSources) { |
| 878 | + groups.push({ |
| 879 | + id: source.id, |
| 880 | + name: source.name, |
| 881 | + color: source.color, |
| 882 | + issues: this.findSegmentIssues((verse) => this.sourceVerseData(source, verse)), |
| 883 | + }); |
| 884 | + } |
| 885 | +
|
| 886 | + this.issueGroups = groups; |
| 887 | + return; |
868 | 888 | } |
869 | 889 |
|
870 | | - this.issueGroups = groups; |
871 | | - this.activeIssueTab = 'current'; |
872 | | - this.showIssues = true; |
| 890 | + this.issuesError = false; |
| 891 | + this.issuesLoading = true; |
| 892 | + this.issueGroups = [{ id: 'current', name: 'Current', color: '#198754', issues: [] }]; |
| 893 | +
|
| 894 | + let issues = []; |
| 895 | + try { |
| 896 | + // Validate the live in-browser state, so issues the reviewer already |
| 897 | + // fixed (but has not saved) are not reported. Nothing is persisted. |
| 898 | + issues = await this.fetchServerIssues(); |
| 899 | + } catch (error) { |
| 900 | + this.issuesError = true; |
| 901 | + } |
| 902 | +
|
| 903 | + // The one check the server cannot do: the audio can continue past the last |
| 904 | + // ayah using the real decoded duration (the server only knows the stored |
| 905 | + // file.duration_ms). |
| 906 | + const durationIssue = this.fileDurationIssue(); |
| 907 | + if (durationIssue) issues.push(durationIssue); |
| 908 | +
|
| 909 | + issues.sort((a, b) => (a.severity === 'major' ? 0 : 1) - (b.severity === 'major' ? 0 : 1)); |
| 910 | +
|
| 911 | + this.issueGroups = [{ id: 'current', name: 'Current', color: '#198754', issues }]; |
| 912 | + this.issuesLoading = false; |
| 913 | + }, |
| 914 | + async fetchServerIssues() { |
| 915 | + const recitation = this.$store.state.recitation; |
| 916 | + const segmentsUrl = this.$store.state.segmentsUrl; |
| 917 | +
|
| 918 | + const csrfTokenElement = document.querySelector('meta[name="csrf-token"]'); |
| 919 | + const headers = { 'Content-Type': 'application/json' }; |
| 920 | + if (csrfTokenElement) headers['X-CSRF-Token'] = csrfTokenElement.content; |
| 921 | +
|
| 922 | + const response = await fetch(`/${segmentsUrl}/${recitation}/validate_segments.json`, { |
| 923 | + method: 'post', |
| 924 | + headers, |
| 925 | + body: JSON.stringify({ chapter_id: this.chapter, segments: this.segments }), |
| 926 | + }); |
| 927 | +
|
| 928 | + if (!response.ok) throw new Error(`Validation request failed (${response.status})`); |
| 929 | +
|
| 930 | + const json = await response.json(); |
| 931 | +
|
| 932 | + return (json.issues || []).map((issue, index) => ({ |
| 933 | + verse: issue.verse || (issue.key ? Number(String(issue.key).split(':').pop()) : null), |
| 934 | + key: `server-${index}`, |
| 935 | + severity: issue.severity === 'bg-danger' ? 'major' : 'minor', |
| 936 | + message: issue.text, |
| 937 | + })); |
| 938 | + }, |
| 939 | + fileDurationIssue() { |
| 940 | + const TRAILING_GAP_THRESHOLD_MS = 1000; |
| 941 | + const audioDuration = this.playerDurationMs(); |
| 942 | + if (!audioDuration) return null; |
| 943 | +
|
| 944 | + const present = (value) => value !== undefined && value !== null && value !== ''; |
| 945 | +
|
| 946 | + let lastVerse = null; |
| 947 | + let lastEnd = null; |
| 948 | + for (let verse = 1; verse <= this.versesCount; verse++) { |
| 949 | + const data = this.segments[`${this.chapter}:${verse}`]; |
| 950 | + if (data && present(data.timestamp_to)) { |
| 951 | + lastVerse = verse; |
| 952 | + lastEnd = Number(data.timestamp_to); |
| 953 | + } |
| 954 | + } |
| 955 | + if (lastEnd === null) return null; |
| 956 | +
|
| 957 | + const gap = audioDuration - lastEnd; |
| 958 | + if (gap <= TRAILING_GAP_THRESHOLD_MS) return null; |
| 959 | +
|
| 960 | + return { |
| 961 | + verse: lastVerse, |
| 962 | + key: 'client-file-duration', |
| 963 | + severity: 'major', |
| 964 | + message: `Audio continues ${Math.round(gap / 1000)}s past the last ayah ends (unsegmented tail)`, |
| 965 | + }; |
873 | 966 | }, |
874 | 967 | goToIssue(verse) { |
875 | 968 | this.$store.commit('CHANGE_AYAH', { to: verse }); |
|
0 commit comments