Skip to content

Commit 98e4282

Browse files
committed
Adds admin page for approving and rejecting album submissions.
1 parent 70c215c commit 98e4282

19 files changed

Lines changed: 823 additions & 22 deletions

Chavah.NetCore/Common/Extensions/EmailSenderExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ public static void QueueAlbumSubmissionEmail(this IEmailService emailSender, Alb
6363
<p>Uploaded by {userName ?? "unknown user"} {album.ArtistEmail}</p>
6464
<p><img src='{album.AlbumArt.Url}' style='max-width: 500px; max-height: auto' /></p>
6565
<ol>
66-
{album.Songs.Select(s => $"<li><a href='{s.Url}'>{s.Name}</a> <audio controls src='{s.Url}'></audio></li>")}
66+
{string.Join(' ', album.Songs.Select(s => $"<li><a href='{s.Url}'>{s.Name}</a> <audio controls src='{s.Url}'></audio></li>"))}
6767
</ol>
68-
<p>Please visit <a href='https://messianicradio.com/#/admin/albums'>Chavah admin></a> to approve or reject.</p>
68+
<p>Please visit <a href='https://messianicradio.com/#/admin/albums/submissions'>Chavah admin</a> to approve or reject.</p>
6969
";
7070
emailSender.QueueSendEmail(recipient, subject, body);
7171
}

Chavah.NetCore/Controllers/AlbumsController.cs

Lines changed: 144 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,142 @@ public async Task<PagedList<Album>> GetAll(int skip, int take, string search)
7676
};
7777
}
7878

79+
/// <summary>
80+
/// Gets the list of album submissions.
81+
/// </summary>
82+
/// <returns></returns>
83+
[HttpGet]
84+
[Authorize(Roles = AppUser.AdminRole)]
85+
public async Task<List<AlbumSubmissionByArtist>> GetSubmissions()
86+
{
87+
return await DbSession.Query<AlbumSubmissionByArtist>()
88+
.Where(s => s.Status == ApprovalStatus.Pending)
89+
.Take(100)
90+
.ToListAsync();
91+
}
92+
93+
/// <summary>
94+
/// Approves an album submission, converting it to a permanent album with songs live on Chavah.
95+
/// </summary>
96+
/// <param name="submission"></param>
97+
/// <returns>The ID of the new album.</returns>
98+
[HttpPost]
99+
[Authorize(Roles = AppUser.AdminRole)]
100+
public async Task<string> ApproveSubmission([FromBody] AlbumSubmissionByArtist submission)
101+
{
102+
// Put the album art on the CDN with the correct file name containing the album name, artist name, etc.
103+
// Then Delete the temporary album art file.
104+
var albumArtUriCdn = await cdnManagerService.UploadAlbumArtAsync(submission.AlbumArt.Url, submission.Artist, submission.Name, System.IO.Path.GetExtension(submission.AlbumArt.CdnId));
105+
await cdnManagerService.DeleteTempFileAsync(submission.AlbumArt.CdnId);
106+
107+
// Store the new album
108+
var newAlbum = new Album
109+
{
110+
AlbumArtUri = albumArtUriCdn,
111+
Artist = submission.Artist,
112+
BackgroundColor = submission.BackColor,
113+
ForegroundColor = submission.ForeColor,
114+
IsVariousArtists = submission.Artist == "Various Artists",
115+
MutedColor = submission.MutedColor,
116+
Name = submission.Name,
117+
SongCount = submission.Songs.Count,
118+
TextShadowColor = submission.TextShadowColor,
119+
};
120+
await DbSession.StoreAsync(newAlbum);
121+
122+
// Store the Artist as well if we don't already have an artist for them.
123+
Artist? existingArtist = null;
124+
if (submission.Artist != "Various Artists")
125+
{
126+
// Grab the existing artist only if we're not the special case "Various Artists"
127+
existingArtist = await DbSession.Query<Artist>()
128+
.FirstOrDefaultAsync(a => a.Name == submission.Artist);
129+
}
130+
131+
// Create the artist if necessary.
132+
if (existingArtist == null)
133+
{
134+
existingArtist = new Artist
135+
{
136+
Bio = "",
137+
Name = submission.Artist,
138+
Contact = submission.ArtistEmail,
139+
Disambiguation = submission.Artist == "Various Artists" ? submission.Name : null,
140+
DonationUrl = string.IsNullOrWhiteSpace(submission.ArtistPayPalEmail) ? null : new Uri($"paypal:?email={Uri.EscapeDataString(submission.ArtistPayPalEmail)}")
141+
};
142+
await DbSession.StoreAsync(existingArtist);
143+
}
144+
145+
// Store the songs in the DB.
146+
var songNumber = 1;
147+
var songsWithTempFiles = new Dictionary<Song, TempFile>(submission.Songs.Count);
148+
foreach (var tempFile in submission.Songs)
149+
{
150+
var (english, hebrew) = tempFile.Name.GetEnglishAndHebrew();
151+
var song = new Song
152+
{
153+
Album = submission.Name,
154+
AlbumHebrewName = null,
155+
Artist = submission.Artist,
156+
AlbumArtUri = albumArtUriCdn,
157+
CommunityRankStanding = CommunityRankStanding.Normal,
158+
ContributingArtists = [.. english.GetFeaturedArtistsFromSongName()],
159+
Genres = [],
160+
Name = english,
161+
HebrewName = hebrew,
162+
Number = songNumber,
163+
PurchaseUri = submission.PurchaseUrl,
164+
UploadDate = DateTime.UtcNow,
165+
AlbumId = newAlbum.Id,
166+
ArtistId = existingArtist.Id,
167+
AlbumColors = new AlbumColors
168+
{
169+
Background = newAlbum.BackgroundColor,
170+
Foreground = newAlbum.ForegroundColor,
171+
Muted = newAlbum.MutedColor,
172+
TextShadow = newAlbum.TextShadowColor
173+
},
174+
Uri = tempFile.Url
175+
};
176+
await DbSession.StoreAsync(song);
177+
178+
songsWithTempFiles.Add(song, tempFile);
179+
songNumber++;
180+
}
181+
182+
// Finally, we can mark the submission as approved.
183+
submission.Status = ApprovalStatus.Approved;
184+
await DbSession.SaveChangesAsync();
185+
186+
// Now that the songs are saved in the database, migrate their URIs from temp file to
187+
// a final file name that includes song, artist, album, etc.ccting
188+
foreach (var (song, tempFile) in songsWithTempFiles)
189+
{
190+
songUploadService.MoveSongUriFromTemporaryToFinal(tempFile, submission, song.Number, song.Id!);
191+
}
192+
193+
return newAlbum.Id!;
194+
}
195+
196+
/// <summary>
197+
/// Rejects an album submission. The album won't be converted to a permanent album on the station. The song uploads and album art upload will be deleted.
198+
/// </summary>
199+
/// <param name="submission">The album submission to reject.</param>
200+
/// <returns></returns>
201+
[HttpPost]
202+
[Authorize(Roles = AppUser.AdminRole)]
203+
public async Task RejectSubmission([FromBody] AlbumSubmissionByArtist submission)
204+
{
205+
var existingSubmission = await DbSession.LoadAsync<AlbumSubmissionByArtist>(submission.Id);
206+
if (existingSubmission == null)
207+
{
208+
throw new ArgumentException($"Could not find album submission with ID {submission.Id}");
209+
}
210+
211+
// Mark it as rejected. The submission and its temp files will be deleted at a later date during a background task; see AlbumSubmissionCleanup.cs
212+
existingSubmission.Status = ApprovalStatus.Rejected;
213+
}
214+
79215
[HttpGet]
80216
public async Task<IActionResult> GetAlbumArtBySongId(string songId)
81217
{
@@ -194,32 +330,22 @@ public async Task<TempFile> UploadTempFile([FromForm] IFormFile file)
194330
throw new InvalidOperationException($"Cannot upload temp file because there have been too many temp files uploaded recently.");
195331
}
196332

197-
// Clean up any old temp files.
198-
var tempFilesReadyForDeletion = await DbSession.Query<TempFile>()
199-
.Where(t => t.CreatedAt < DateTimeOffset.UtcNow.AddDays(30))
200-
.ToListAsync();
201-
if (tempFilesReadyForDeletion.Count > 0)
202-
{
203-
foreach (var tempFileToDelete in tempFilesReadyForDeletion)
204-
{
205-
await cdnManagerService.DeleteTempFileAsync(tempFileToDelete.CdnId);
206-
DbSession.Delete(tempFileToDelete);
207-
}
208-
await DbSession.SaveChangesAsync();
209-
}
210-
211333
// OK, we're cool to upload it to our CDN.
212334
using var mediaFileStream = file.OpenReadStream();
213335
var fileExtension = isMp3 ? ".mp3" : isPng ? ".png" : isWebp ? ".webp" : ".jpg";
214336
var fileName = Guid.NewGuid().ToString() + fileExtension;
215337
var tempFileUri = await cdnManagerService.UploadTempFileAsync(mediaFileStream, fileName);
216-
return new TempFile
338+
var tempFile = new TempFile
217339
{
218340
Url = tempFileUri,
219341
Name = fileName,
220342
CdnId = fileName,
221-
Id = $"TempFiles/{fileName}/{DateTime.UtcNow:O}"
343+
Id = $"TempFiles/{fileName}/{DateTime.UtcNow:O}",
344+
CreatedAt = DateTimeOffset.UtcNow
222345
};
346+
347+
await DbSession.StoreAsync(tempFile);
348+
return tempFile;
223349
}
224350

225351
[HttpPost]
@@ -343,6 +469,8 @@ public async Task<string> UploadAlbumSubmissionByArtist([FromBody] AlbumSubmissi
343469

344470
// Save it in the database.
345471
album.Id = $"AlbumSubmissionsByArtist/{album.Artist}/{album.Name}/{DateTime.UtcNow:O}";
472+
album.Status = ApprovalStatus.Pending;
473+
album.CreatedAt = DateTimeOffset.UtcNow;
346474
await DbSession.StoreAsync(album);
347475

348476
// Notify admins.

Chavah.NetCore/Models/AlbumSubmissionByArtist.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace BitShuva.Chavah.Models
1+
using System;
2+
3+
namespace BitShuva.Chavah.Models
24
{
35
/// <summary>
46
/// An album submitted by an end user, usually the artist themselves.
@@ -19,5 +21,15 @@ public class AlbumSubmissionByArtist : AlbumUpload
1921
/// The PayPal email of the artist. This is used to pay the artist Messiah's Music Fund disbursements.
2022
/// </summary>
2123
public required string ArtistPayPalEmail { get; set; }
24+
25+
/// <summary>
26+
/// The status of the album submission.
27+
/// </summary>
28+
public ApprovalStatus Status { get; set; } = ApprovalStatus.Pending;
29+
30+
/// <summary>
31+
/// The date and time when the album submission was uploaded.
32+
/// </summary>
33+
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
2234
}
2335
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace BitShuva.Chavah.Models
2+
{
3+
public enum ApprovalStatus
4+
{
5+
Pending,
6+
Approved,
7+
Rejected
8+
}
9+
}

Chavah.NetCore/Models/Artist.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ public class Artist
3333
/// </summary>
3434
public bool HasDeclinedDonations { get; set; }
3535

36+
/// <summary>
37+
/// Gets the contact for the artist.
38+
/// </summary>
39+
public string Contact { get; set; }
40+
3641
/// <summary>
3742
/// Gets the name of the artist including any disambiguation.
3843
/// </summary>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System;
2+
using System.Linq;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
6+
using BitShuva.Chavah.Models;
7+
8+
using Microsoft.Extensions.Logging;
9+
10+
using Raven.Client.Documents;
11+
using Raven.Client.Documents.Linq;
12+
13+
namespace BitShuva.Chavah.Services;
14+
15+
/// <summary>
16+
/// Background service that periodically checks for old album submissions that have been are no longer in the pending state. When it finds them, it will delete it and all of its temporary file uploads.
17+
/// It also cleans up any TemporaryFile documents that are older than a certain amount of time.
18+
/// </summary>
19+
public class AlbumSubmissionCleanup : TimedBackgroundServiceBase
20+
{
21+
private readonly ICdnManagerService cdn;
22+
private readonly IDocumentStore db;
23+
24+
private readonly TimeSpan rejectedSubmissionMaxAge = TimeSpan.FromDays(30 * 3); // 3 months
25+
private readonly TimeSpan tempFileMaxAge = TimeSpan.FromDays(30 * 6); // 6 months
26+
27+
public AlbumSubmissionCleanup(
28+
ICdnManagerService cdn,
29+
IDocumentStore db,
30+
ILogger<AlbumSubmissionCleanup> logger)
31+
: base(dueTime: TimeSpan.FromHours(24), intervalTime: TimeSpan.FromDays(7), logger)
32+
{
33+
this.cdn = cdn;
34+
this.db = db;
35+
}
36+
37+
public override async Task DoWorkAsync(CancellationToken cancelToken)
38+
{
39+
await DeleteCompletedAlbumSubmissions();
40+
await DeleteOldTempFiles();
41+
}
42+
43+
private async Task DeleteOldTempFiles()
44+
{
45+
using var dbSession = db.OpenAsyncSession();
46+
var oldTempFiles = await dbSession.Query<TempFile>()
47+
.Where(t => t.CreatedAt < DateTime.UtcNow.Subtract(tempFileMaxAge))
48+
.Take(100)
49+
.ToListAsync();
50+
foreach (var tempFile in oldTempFiles)
51+
{
52+
await cdn.DeleteTempFileAsync(tempFile.CdnId);
53+
dbSession.Delete(tempFile);
54+
}
55+
}
56+
57+
private async Task DeleteCompletedAlbumSubmissions()
58+
{
59+
using var dbSession = db.OpenAsyncSession();
60+
var completedSubmissions = await dbSession.Query<AlbumSubmissionByArtist>()
61+
.Where(a => a.Status != ApprovalStatus.Pending && a.CreatedAt < DateTime.UtcNow.Subtract(rejectedSubmissionMaxAge))
62+
.Take(10)
63+
.ToListAsync();
64+
foreach (var submission in completedSubmissions)
65+
{
66+
try
67+
{
68+
// Delete the album submission document.
69+
dbSession.Delete(submission);
70+
71+
// Delete the temporary media files from the CDN.
72+
var tempFilesToDelete = submission.Songs.Concat([submission.AlbumArt]);
73+
foreach (var tempFile in tempFilesToDelete)
74+
{
75+
await cdn.DeleteTempFileAsync(tempFile.CdnId);
76+
}
77+
78+
await dbSession.SaveChangesAsync();
79+
}
80+
catch (Exception error)
81+
{
82+
logger.LogError(error, "Unable to delete rejected album submission with ID {submissionId}. You may want to manually delete this submission and its temporary media files. Album name {albumName} by artist {artist}", submission.Id, submission.Name, submission.Artist);
83+
}
84+
}
85+
}
86+
}

Chavah.NetCore/Startup.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public void ConfigureServices(IServiceCollection services)
7373
services.AddBackgroundQueueWithLogging(1, TimeSpan.FromSeconds(5));
7474
services.AddHostedService<BlogPostNotificationCreator>();
7575
services.AddHostedService<EmailRetryService>();
76+
services.AddHostedService<AlbumSubmissionCleanup>();
7677
services.AddCacheBustedAngularViews("/views");
7778

7879
// Add RavenDB document store, session, and migrations.
@@ -97,7 +98,9 @@ public void ConfigureServices(IServiceCollection services)
9798
.AddDefaultTokenProviders()
9899
.AddRavenDbIdentityStores<AppUser>(); // Use Raven for users and roles.
99100

101+
#if !DEBUG
100102
services.InstallIndexes();
103+
#endif
101104
services.AddMemoryCache();
102105

103106
services.AddApiVersioning(v =>

Chavah.NetCore/compilerconfig.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,13 @@
110110
{
111111
"outputFile": "wwwroot/css/app/musicSubmission.css",
112112
"inputFile": "wwwroot/css/app/musicSubmission.less"
113+
},
114+
{
115+
"outputFile": "wwwroot/css/app/adminAlbumSubmissions.css",
116+
"inputFile": "wwwroot/css/app/adminAlbumSubmissions.less"
117+
},
118+
{
119+
"outputFile": "wwwroot/css/app/albumPreviewer.css",
120+
"inputFile": "wwwroot/css/app/albumPreviewer.less"
113121
}
114-
]
122+
]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
.admin-album-submissions-page {
2+
.submissions-list-container {
3+
margin-top: 20px;
4+
}
5+
6+
.submissions-list {
7+
overflow: auto;
8+
max-height: 700px;
9+
}
10+
11+
.songs-container {
12+
margin-top: 16px;
13+
gap: 16px;
14+
15+
.song-container {
16+
gap: 4px;
17+
align-items: baseline;
18+
}
19+
}
20+
21+
.footer-buttons {
22+
margin-top: 20px;
23+
gap: 8px;
24+
}
25+
}

0 commit comments

Comments
 (0)