Skip to content

Commit fe8f970

Browse files
authored
Allow recording and saving audio (#1836)
* move MediaUri into a media folder * handle saving files locally * store recordings as a file with a generated name * prevent reporting an infinite duration which can crash the browser due to how the slider works * generate a filename based on the time and guess the extension by mimetype * save files locally using harmony resources * show an error for files which are too big * handle file saving and upload to lexbox * expose a media files gql endpoint * attempt to upload pending media files on sync * setup permission manager for android blazor web client
1 parent 52f0ba3 commit fe8f970

File tree

54 files changed

+1266
-84
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1266
-84
lines changed

backend/FwHeadless/LexboxFwDataMediaAdapter.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
using LexCore.Exceptions;
55
using Microsoft.Extensions.Options;
66
using MiniLcm;
7+
using MiniLcm.Media;
78
using SIL.LCModel;
9+
using MediaFile = LexCore.Entities.MediaFile;
810

911
namespace FwHeadless;
1012

@@ -13,17 +15,17 @@ public class LexboxFwDataMediaAdapter(IOptions<FwHeadlessConfig> config, MediaFi
1315
{
1416
public MediaUri MediaUriFromPath(string path, LcmCache cache)
1517
{
16-
var fullPath = Path.Join(cache.LangProject.LinkedFilesRootDir, path);
17-
if (!File.Exists(fullPath)) return MediaUri.NotFound;
18-
return MediaUriForMediaFile(mediaFileService.FindMediaFile(config.Value.LexboxProjectId(cache), fullPath));
18+
if (!Path.IsPathRooted(path)) throw new ArgumentException("Path must be absolute, " + path, nameof(path));
19+
if (!File.Exists(path)) return MediaUri.NotFound;
20+
return MediaUriForMediaFile(mediaFileService.FindMediaFile(config.Value.LexboxProjectId(cache), path));
1921
}
2022

21-
public string PathFromMediaUri(MediaUri mediaUri, LcmCache cache)
23+
public string? PathFromMediaUri(MediaUri mediaUri, LcmCache cache)
2224
{
23-
var mediaFile = mediaFileService.FindMediaFile(mediaUri.FileId) ??
24-
throw new NotFoundException($"Unable to find file {mediaUri.FileId}.", nameof(MediaFile));
25+
var mediaFile = mediaFileService.FindMediaFile(mediaUri.FileId);
26+
if (mediaFile is null) return null;
2527
var fullFilePath = Path.Join(cache.ProjectId.ProjectFolder, mediaFile.Filename);
26-
return Path.GetRelativePath(cache.LangProject.LinkedFilesRootDir, fullFilePath);
28+
return fullFilePath;
2729
}
2830

2931
private MediaUri MediaUriForMediaFile(MediaFile mediaFile)

backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/FwDataTestsKernel.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public static IServiceCollection AddTestFwDataBridge(this IServiceCollection ser
1111
{
1212
services.AddFwDataBridge();
1313
services.TryAddSingleton<IConfiguration>(_ => new ConfigurationRoot([]));
14+
//this path is typically not used for projects (they're in memory) but it is used for media
15+
services.Configure<FwDataBridgeConfig>(config => config.ProjectsFolder = Path.GetFullPath(Path.Combine(".", "fw-test-projects")));
1416
if (mockProjectLoader)
1517
{
1618
services.AddSingleton<MockFwProjectLoader>();

backend/FwLite/FwDataMiniLcmBridge.Tests/Fixtures/ProjectLoaderFixture.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ public class ProjectLoaderFixture : IDisposable
1111
private readonly ServiceProvider _serviceProvider;
1212
private readonly IOptions<FwDataBridgeConfig> _config;
1313
public MockFwProjectLoader MockFwProjectLoader { get; }
14+
public IServiceProvider Services => _serviceProvider;
1415

1516
public ProjectLoaderFixture()
1617
{
1718
//todo make mock of IProjectLoader so we can load from test projects
18-
var provider = new ServiceCollection().AddTestFwDataBridge().BuildServiceProvider();
19+
var provider = new ServiceCollection()
20+
.AddTestFwDataBridge()
21+
.BuildServiceProvider();
1922
_serviceProvider = provider;
2023
_fwDataFactory = provider.GetRequiredService<FwDataFactory>();
2124
MockFwProjectLoader = provider.GetRequiredService<MockFwProjectLoader>();

backend/FwLite/FwDataMiniLcmBridge.Tests/MediaFileTests.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using FwDataMiniLcmBridge.Api;
22
using FwDataMiniLcmBridge.Media;
33
using FwDataMiniLcmBridge.Tests.Fixtures;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using MiniLcm.Media;
46
using MiniLcm.Models;
57
using SIL.LCModel.Infrastructure;
68

@@ -11,9 +13,11 @@ public class MediaFileTests : IAsyncLifetime
1113
{
1214
private readonly FwDataMiniLcmApi _api;
1315
private readonly WritingSystemId _audioWs = "en-Zxxx-x-audio";
16+
private IMediaAdapter _mediaAdapter;
1417

1518
public MediaFileTests(ProjectLoaderFixture fixture)
1619
{
20+
_mediaAdapter = fixture.Services.GetRequiredService<IMediaAdapter>();
1721
_api = fixture.NewProjectApi("media-file-test", "en", "en");
1822
}
1923

@@ -60,10 +64,10 @@ private async Task<Guid> AddFileDirectly(string fileName, string? contents, bool
6064

6165
private async Task<Guid> StoreFileContentsAsync(string fileName, string? contents)
6266
{
63-
var fwFilePath = Path.Combine(FwDataMiniLcmApi.AudioVisualFolder, fileName);
64-
var filePath = Path.Combine(_api.Cache.LangProject.LinkedFilesRootDir, fwFilePath);
67+
var filePath = Path.Combine(_api.Cache.LangProject.LinkedFilesRootDir, FwDataMiniLcmApi.AudioVisualFolder, fileName);
6568
await File.WriteAllTextAsync(filePath, contents);
66-
return LocalMediaAdapter.NewGuidV5(fwFilePath);
69+
//using media adapter to ensure it's cache is updated with the new file
70+
return _mediaAdapter.MediaUriFromPath(filePath, _api.Cache).FileId;
6771
}
6872

6973
private string GetFwAudioValue(Guid id)
@@ -78,7 +82,7 @@ private string GetFwAudioValue(Guid id)
7882
public async Task GetEntry_MapsFilePathsFromAudioWs()
7983
{
8084
var fileName = "MapsAFileReferenceIntoAMediaUri.txt";
81-
var fileGuid = LocalMediaAdapter.NewGuidV5(Path.Combine(FwDataMiniLcmApi.AudioVisualFolder, fileName));
85+
var fileGuid = LocalMediaAdapter.NewGuidV5(Path.Combine(_api.Cache.LangProject.LinkedFilesRootDir, FwDataMiniLcmApi.AudioVisualFolder, fileName));
8286
var entryId = await AddFileDirectly(fileName, "test");
8387

8488
var entry = await _api.GetEntry(entryId);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using FwDataMiniLcmBridge.Api;
2+
using FwDataMiniLcmBridge.Tests.Fixtures;
3+
4+
namespace FwDataMiniLcmBridge.Tests.MiniLcmTests;
5+
6+
[Collection(ProjectLoaderFixture.Name)]
7+
public class MediaTests : MediaTestsBase
8+
{
9+
private readonly ProjectLoaderFixture _fixture;
10+
11+
public MediaTests(ProjectLoaderFixture fixture)
12+
{
13+
_fixture = fixture;
14+
}
15+
16+
protected override Task<IMiniLcmApi> NewApi()
17+
{
18+
return Task.FromResult<IMiniLcmApi>(_fixture.NewProjectApi("media-test", "en", "en"));
19+
}
20+
21+
public override async Task InitializeAsync()
22+
{
23+
await base.InitializeAsync();
24+
var projectFolder = ((FwDataMiniLcmApi)Api).Cache.LangProject.LinkedFilesRootDir;
25+
Directory.CreateDirectory(projectFolder);
26+
}
27+
28+
public override async Task DisposeAsync()
29+
{
30+
var projectFolder = ((FwDataMiniLcmApi)Api).Cache.ProjectId.ProjectFolder;
31+
if (Directory.Exists(projectFolder)) Directory.Delete(projectFolder, true);
32+
await base.DisposeAsync();
33+
}
34+
}

backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Microsoft.Extensions.Options;
1010
using MiniLcm;
1111
using MiniLcm.Exceptions;
12+
using MiniLcm.Media;
1213
using MiniLcm.Models;
1314
using MiniLcm.SyncHelpers;
1415
using MiniLcm.Validators;
@@ -718,14 +719,17 @@ private string ToMediaUri(string tsString)
718719
//rooted media paths aren't supported
719720
if (Path.IsPathRooted(tsString))
720721
throw new ArgumentException("Media path must be relative", nameof(tsString));
721-
return mediaAdapter.MediaUriFromPath(Path.Combine(AudioVisualFolder, tsString), Cache).ToString();
722+
var fullFilePath = Path.Join(Cache.LangProject.LinkedFilesRootDir, AudioVisualFolder, tsString);
723+
return mediaAdapter.MediaUriFromPath(fullFilePath, Cache).ToString();
722724
}
723725

724-
internal string FromMediaUri(string mediaUri)
726+
internal string FromMediaUri(string mediaUriString)
725727
{
726728
//path includes `AudioVisual` currently
727-
var path = mediaAdapter.PathFromMediaUri(new MediaUri(mediaUri), Cache);
728-
return Path.GetRelativePath(AudioVisualFolder, path);
729+
MediaUri mediaUri = new MediaUri(mediaUriString);
730+
var path = mediaAdapter.PathFromMediaUri(mediaUri, Cache);
731+
if (path is null) throw new NotFoundException($"Unable to find file {mediaUri.FileId}.", nameof(MediaFile));
732+
return Path.GetRelativePath(Path.Join(Cache.LangProject.LinkedFilesRootDir, AudioVisualFolder), path);
729733
}
730734

731735
internal RichString? ToRichString(ITsString? tsString)
@@ -817,7 +821,7 @@ public IAsyncEnumerable<Entry> SearchEntries(string query, QueryOptions? options
817821
{
818822
if (string.IsNullOrEmpty(query)) return null;
819823
return entry => entry.CitationForm.SearchValue(query) ||
820-
entry.LexemeFormOA.Form.SearchValue(query) ||
824+
entry.LexemeFormOA?.Form.SearchValue(query) is true ||
821825
entry.AllSenses.Any(s => s.Gloss.SearchValue(query));
822826
}
823827

@@ -1536,8 +1540,55 @@ private static void ValidateOwnership(ILexExampleSentence lexExampleSentence, Gu
15361540
public Task<ReadFileResponse> GetFileStream(MediaUri mediaUri)
15371541
{
15381542
if (mediaUri == MediaUri.NotFound) return Task.FromResult(new ReadFileResponse(ReadFileResult.NotFound));
1539-
string fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, mediaAdapter.PathFromMediaUri(mediaUri, Cache));
1543+
var pathFromMediaUri = mediaAdapter.PathFromMediaUri(mediaUri, Cache);
1544+
if (pathFromMediaUri is not {Length: > 0}) return Task.FromResult(new ReadFileResponse(ReadFileResult.NotFound));
1545+
string fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, pathFromMediaUri);
15401546
if (!File.Exists(fullPath)) return Task.FromResult(new ReadFileResponse(ReadFileResult.NotFound));
15411547
return Task.FromResult(new ReadFileResponse(File.OpenRead(fullPath), Path.GetFileName(fullPath)));
15421548
}
1549+
1550+
public async Task<UploadFileResponse> SaveFile(Stream stream, LcmFileMetadata metadata)
1551+
{
1552+
if (stream.SafeLength() > MediaFile.MaxFileSize) return new UploadFileResponse(UploadFileResult.TooBig);
1553+
var fullPath = Path.Combine(Cache.LangProject.LinkedFilesRootDir, TypeToLinkedFolder(metadata.MimeType), Path.GetFileName(metadata.Filename));
1554+
1555+
if (File.Exists(fullPath))
1556+
return new UploadFileResponse(mediaAdapter.MediaUriFromPath(fullPath, Cache), savedToLexbox: false, newResource: false);
1557+
var directory = Path.GetDirectoryName(fullPath);
1558+
if (directory is not null)
1559+
{
1560+
try
1561+
{
1562+
Directory.CreateDirectory(directory);
1563+
}
1564+
catch (Exception ex)
1565+
{
1566+
logger.LogError(ex, "Failed to create directory {Directory} for file {Filename}", directory, metadata.Filename);
1567+
return new UploadFileResponse($"Failed to create directory: {ex.Message}");
1568+
}
1569+
}
1570+
1571+
try
1572+
{
1573+
await using var fileStream = File.Create(fullPath);
1574+
await stream.CopyToAsync(fileStream);
1575+
return new UploadFileResponse(mediaAdapter.MediaUriFromPath(fullPath, Cache), savedToLexbox: false, newResource: true);
1576+
}
1577+
catch (Exception ex)
1578+
{
1579+
logger.LogError(ex, "Failed to save file {Filename} to {Path}", metadata.Filename, fullPath);
1580+
return new UploadFileResponse($"Failed to save file: {ex.Message}");
1581+
}
1582+
}
1583+
1584+
private string TypeToLinkedFolder(string mimeType)
1585+
{
1586+
return mimeType switch
1587+
{
1588+
{ } s when s.StartsWith("audio/") => AudioVisualFolder,
1589+
{ } s when s.StartsWith("video/") => AudioVisualFolder,
1590+
{ } s when s.StartsWith("image/") => "Pictures",
1591+
_ => "Others"
1592+
};
1593+
}
15431594
}

backend/FwLite/FwDataMiniLcmBridge/Api/UpdateProxy/UpdateDictionaryProxy.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Collections;
22
using System.Diagnostics.CodeAnalysis;
33
using MiniLcm;
4+
using MiniLcm.Media;
45
using MiniLcm.Models;
56
using SIL.LCModel.Core.KernelInterfaces;
67
using SIL.LCModel.Core.Text;

backend/FwLite/FwDataMiniLcmBridge/LexEntryFilterMapProvider.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ public class LexEntryFilterMapProvider : EntryFilterMapProvider<ILexEntry>
2828
public override Expression<Func<ILexEntry, string, object?>> EntrySensesGloss => (entry, ws) => entry.AllSenses.Select(s => s.PickText(s.Gloss, ws));
2929
public override Expression<Func<ILexEntry, string, object?>> EntrySensesDefinition => (entry, ws) => entry.AllSenses.Select(s => s.PickText(s.Definition, ws));
3030
public override Expression<Func<ILexEntry, string, object?>> EntryNote => (entry, ws) => entry.PickText(entry.Comment, ws);
31-
public override Expression<Func<ILexEntry, string, object?>> EntryLexemeForm => (entry, ws) => entry.PickText(entry.LexemeFormOA.Form, ws);
31+
public override Expression<Func<ILexEntry, string, object?>> EntryLexemeForm => (entry, ws) =>
32+
entry.LexemeFormOA == null ? null : entry.PickText(entry.LexemeFormOA.Form, ws);
3233
public override Expression<Func<ILexEntry, string, object?>> EntryCitationForm => (entry, ws) => entry.PickText(entry.CitationForm, ws);
3334
public override Expression<Func<ILexEntry, string, object?>> EntryLiteralMeaning => (entry, ws) => entry.PickText(entry.LiteralMeaning, ws);
3435
public override Expression<Func<ILexEntry, object?>> EntryComplexFormTypes => e => EmptyToNull(e.ComplexFormEntryRefs.SelectMany(r => r.ComplexEntryTypesRS));
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using MiniLcm;
2+
using MiniLcm.Media;
23
using SIL.LCModel;
34

45
namespace FwDataMiniLcmBridge.Media;
@@ -8,7 +9,7 @@ public interface IMediaAdapter
89
/// <summary>
910
/// get the MediaUri representing a file, can be used later to get the path back
1011
/// </summary>
11-
/// <param name="path">the path relative to LinkedFiles to find the file at</param>
12+
/// <param name="path">the full file path must be inside the project LinkedFiles directory</param>
1213
/// <param name="cache">the current project</param>
1314
/// <returns>a media uri which can later be used to get the path</returns>
1415
MediaUri MediaUriFromPath(string path, LcmCache cache);
@@ -17,6 +18,6 @@ public interface IMediaAdapter
1718
/// </summary>
1819
/// <param name="mediaUri"></param>
1920
/// <param name="cache"></param>
20-
/// <returns>the path to the file represented by the mediaUri, relative to the LinkedFiles directory in the given project</returns>
21-
string PathFromMediaUri(MediaUri mediaUri, LcmCache cache);
21+
/// <returns>the full path to the file represented by the mediaUri, will return null when it can't find the file</returns>
22+
string? PathFromMediaUri(MediaUri mediaUri, LcmCache cache);
2223
}

backend/FwLite/FwDataMiniLcmBridge/Media/LocalMediaAdapter.cs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Microsoft.Extensions.Caching.Memory;
33
using MiniLcm;
44
using MiniLcm.Exceptions;
5+
using MiniLcm.Media;
56
using SIL.LCModel;
67
using UUIDNext;
78

@@ -21,19 +22,38 @@ private Dictionary<Guid, string> Paths(LcmCache cache)
2122
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
2223
return Directory
2324
.EnumerateFiles(cache.LangProject.LinkedFilesRootDir, "*", SearchOption.AllDirectories)
24-
.Select(file => Path.GetRelativePath(cache.LangProject.LinkedFilesRootDir, file))
25-
.ToDictionary(file => MediaUriFromPath(file, cache).FileId, file => file);
25+
.ToDictionary(file => PathToUri(file).FileId, file => file);
2626
}) ?? throw new Exception("Failed to get paths");
2727
}
2828

2929
//path is expected to be relative to the LinkedFilesRootDir
3030
public MediaUri MediaUriFromPath(string path, LcmCache cache)
3131
{
32-
if (!File.Exists(Path.Combine(cache.LangProject.LinkedFilesRootDir, path))) return MediaUri.NotFound;
32+
EnsureCorrectRootFolder(path, cache);
33+
if (!File.Exists(path)) return MediaUri.NotFound;
34+
var uri = PathToUri(path);
35+
//this may be a new file, so we need to add it to the cache
36+
Paths(cache)[uri.FileId] = path;
37+
return uri;
38+
}
39+
40+
private void EnsureCorrectRootFolder(string path, LcmCache cache)
41+
{
42+
if (Path.IsPathRooted(path))
43+
{
44+
if (path.StartsWith(cache.LangProject.LinkedFilesRootDir)) return;
45+
throw new ArgumentException("Path must be in the LinkedFilesRootDir", nameof(path));
46+
}
47+
48+
throw new ArgumentException("Path must be absolute, " + path, nameof(path));
49+
}
50+
51+
private static MediaUri PathToUri(string path)
52+
{
3353
return new MediaUri(NewGuidV5(path), LocalMediaAuthority);
3454
}
3555

36-
public string PathFromMediaUri(MediaUri mediaUri, LcmCache cache)
56+
public string? PathFromMediaUri(MediaUri mediaUri, LcmCache cache)
3757
{
3858
var paths = Paths(cache);
3959
if (mediaUri.Authority != LocalMediaAuthority) throw new ArgumentException("MediaUri must be local", nameof(mediaUri));
@@ -42,7 +62,7 @@ public string PathFromMediaUri(MediaUri mediaUri, LcmCache cache)
4262
return path;
4363
}
4464

45-
throw new NotFoundException("Media not found: " + mediaUri.FileId, "MedaiUri");
65+
return null;
4666
}
4767

4868
// produces the same Guid for the same input name

0 commit comments

Comments
 (0)