move project

This commit is contained in:
2025-07-27 00:33:05 +02:00
parent 4544be2999
commit 5f8fb01a9f
100 changed files with 651 additions and 406 deletions

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "C#: <project-name> Debug",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/<relative-path-to-project-folder><project-name>.csproj"
}
]
}

89
AdapterContext.cs Normal file
View File

@@ -0,0 +1,89 @@
using Db.Models;
using Microsoft.EntityFrameworkCore;
public class AdapterContext : DbContext
{
public AdapterContext(DbContextOptions<AdapterContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure your entity mappings here
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<VideoTag>(e =>
{
e.HasKey(vt => new { vt.VideoId, vt.TagId });
e.HasOne(vt => vt.Video)
.WithMany(v => v.VideoTags)
.HasForeignKey(vt => vt.VideoId);
e.HasOne(vt => vt.Tag)
.WithMany(t => t.VideoTags)
.HasForeignKey(vt => vt.TagId);
});
modelBuilder.Entity<Video>(e =>
{
e.HasKey(v => v.Id);
e.Property(v => v.Id).IsRequired();
e.Property(v => v.Extension).IsRequired();
// delete videoTags when a video is deleted but not the tags
e.HasMany(v => v.VideoTags)
.WithOne(vt => vt.Video)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Tag>(e =>
{
e.HasKey(t => t.Name);
e.Property(t => t.Name).IsRequired();
// delete videoTags when a tag is deleted but not the videos
e.HasMany(t => t.VideoTags)
.WithOne(vt => vt.Tag)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Setting>(e =>
{
e.HasKey(s => s.Name);
e.Property(s => s.Name).IsRequired();
e.Property(s => s.Value).IsRequired();
});
}
// Tags a video can have (e.g. "funny", "cat", "dog")
public DbSet<Tag> Tags { get; set; }
// Videos with tags, relative paths, and other metadata
public DbSet<Video> Videos { get; set; }
// Settings for the adapter, such as video path
public DbSet<Setting> Settings { get; set; }
public DbSet<VideoTag> VideoTags { get; set; }
public async Task<T?> GetSettingAsync<T>(SettingName settingName)
{
var setting = await Settings.FindAsync(settingName);
return setting != null ? (T?)Convert.ChangeType(setting.Value, typeof(T)) : default;
}
public async Task SetSettingAsync<T>(SettingName settingName, T value)
{
var setting = await Settings.FindAsync(settingName);
if (setting == null)
{
setting = new Setting { Name = settingName, Value = value?.ToString() ?? string.Empty };
Settings.Add(setting);
}
else
{
setting.Value = value?.ToString() ?? string.Empty;
Settings.Update(setting);
}
await SaveChangesAsync();
}
}

View File

@@ -0,0 +1,241 @@
using System.Diagnostics;
using System.Threading.Tasks;
using Db.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TagVid.Helpers;
using TagVid.Models;
namespace TagVid.Controllers;
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
private readonly AdapterContext _adapterContext;
public HomeController(ILogger<HomeController> logger, AdapterContext adapterContext)
{
_logger = logger;
_adapterContext = adapterContext;
}
public async Task<IActionResult> Index()
{
// check if the video path was set
var pathSettings = await _adapterContext.GetSettingAsync<string>(SettingName.VideoPath);
if (string.IsNullOrEmpty(pathSettings))
{
_logger.LogWarning("Video path is not set. Please configure the video path in settings.");
return RedirectToAction("Settings", "Home");
}
return View();
}
[HttpGet]
[Route("Home/Index/{id}")]
public async Task<IActionResult> Index(string id)
{
throw new NotImplementedException();
}
public async Task<IActionResult> Settings()
{
var vm = new SettingsViewModel
{
VideoPath = await _adapterContext.GetSettingAsync<string>(SettingName.VideoPath)
};
return View(vm);
}
[HttpPost]
public async Task<IActionResult> Settings(SettingsViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
// Validate the video path
if (string.IsNullOrWhiteSpace(model.VideoPath))
{
ModelState.AddModelError(nameof(model.VideoPath), "Video path cannot be empty.");
return View(model);
}
// Check if the path exists
if (!Directory.Exists(model.VideoPath))
{
ModelState.AddModelError(nameof(model.VideoPath), "The specified video path does not exist.");
return View(model);
}
// Check if the path is a directory
if (!System.IO.File.GetAttributes(model.VideoPath).HasFlag(FileAttributes.Directory))
{
ModelState.AddModelError(nameof(model.VideoPath), "The specified path must be a directory.");
return View(model);
}
// Save settings to the database
await _adapterContext.SetSettingAsync(SettingName.VideoPath, model.VideoPath);
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Upload(IFormFile videoFile)
{
_logger.LogInformation("Upload called with file: {FileName}", videoFile?.Name);
if (videoFile == null || videoFile.Length == 0)
{
return RedirectToAction("Index");
}
// Validate the video file
// no size limit for now, but could be added later
if (!videoFile.ContentType.StartsWith("video/"))
{
return RedirectToAction("Index");
}
// Save the video file to the configured path
var videoPath = await _adapterContext.GetSettingAsync<string>(SettingName.VideoPath);
var ext = Path.GetExtension(videoFile.FileName);
if (string.IsNullOrEmpty(videoPath) || !Directory.Exists(videoPath))
{
return RedirectToAction("Settings");
}
// asp.net docu suggests not using .FileName due to security reasons
var id = Guid.NewGuid().ToString();
var filePath = Path.Combine(videoPath, $"{id}{ext}");
using (var stream = new FileStream(filePath, FileMode.Create))
{
await videoFile.CopyToAsync(stream);
}
var data = await new FFProbeHelper<HomeController>(_logger).GetVideoMetadataAsync(filePath);
var video = new Video
{
Id = id,
Duration = data?.Duration ?? TimeSpan.Zero,
Width = data?.Width ?? 0,
Height = data?.Height ?? 0,
Extension = ext,
VideoTags = []
};
_adapterContext.Videos.Add(video);
await _adapterContext.SaveChangesAsync();
// redirect to the video details page
return RedirectToAction("Details", "Home", new { id = video.Id });
}
public async Task<IActionResult> Details(string id)
{
if (string.IsNullOrEmpty(id))
{
return NotFound();
}
var video = await _adapterContext.Videos
.Include(v => v.VideoTags)
.ThenInclude(vt => vt.Tag)
.FirstOrDefaultAsync(v => v.Id == id);
if (video == null)
{
return NotFound();
}
var vm = new DetailsViewModel
{
VideoId = id,
Title = video?.Title ?? string.Empty,
Tags = video?.VideoTags.Select(vt => vt.Tag.Name).ToList() ?? []
};
return View(vm);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Details(DetailsViewModel videoModel)
{
if (string.IsNullOrEmpty(videoModel.VideoId))
{
return NotFound();
}
var existingVideo = await _adapterContext.Videos
.Include(v => v.VideoTags)
.ThenInclude(vt => vt.Tag)
.FirstOrDefaultAsync(v => v.Id == videoModel.VideoId);
if (existingVideo == null)
{
return NotFound();
}
// Update video title
existingVideo.Title = videoModel.Title;
// Update tags
existingVideo.VideoTags.Clear();
foreach (var tagName in videoModel.Tags)
{
var tag = await _adapterContext.Tags.FirstOrDefaultAsync(t => t.Name == tagName);
if (tag == null)
{
tag = new Tag { Name = tagName };
_adapterContext.Tags.Add(tag);
}
existingVideo.VideoTags.Add(new VideoTag { Video = existingVideo, Tag = tag });
}
_adapterContext.Videos.Update(existingVideo);
await _adapterContext.SaveChangesAsync();
return RedirectToAction("Details", new { id = videoModel.VideoId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(string id)
{
if (string.IsNullOrEmpty(id))
{
return NotFound();
}
var video = await _adapterContext.Videos.FindAsync(id);
if (video == null)
{
return NotFound();
}
// Delete the video file from the file system
var videoPath = await _adapterContext.GetSettingAsync<string>(SettingName.VideoPath);
if (!string.IsNullOrEmpty(videoPath))
{
var filePath = Path.Combine(videoPath, $"{video.Id}{video.Extension}");
if (System.IO.File.Exists(filePath))
{
System.IO.File.Delete(filePath);
}
}
// Remove the video from the database
_adapterContext.Videos.Remove(video);
await _adapterContext.SaveChangesAsync();
return RedirectToAction("Index");
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}

View File

@@ -8,10 +8,10 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace tagvid.Migrations
namespace TagVid.Migrations
{
[DbContext(typeof(AdapterContext))]
[Migration("20250726102958_InitialMigration")]
[Migration("20250726215357_InitialMigration")]
partial class InitialMigration
{
/// <inheritdoc />
@@ -26,8 +26,8 @@ namespace tagvid.Migrations
modelBuilder.Entity("Db.Models.Setting", b =>
{
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<int>("Name")
.HasColumnType("int");
b.Property<string>("Value")
.IsRequired()
@@ -35,9 +35,6 @@ namespace tagvid.Migrations
b.HasKey("Name");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Settings");
});
@@ -48,42 +45,25 @@ namespace tagvid.Migrations
b.HasKey("Name");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("Db.Models.Video", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("nvarchar(450)");
b.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Videos");
});
modelBuilder.Entity("Db.Models.VideoMetadata", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<TimeSpan>("Duration")
.ValueGeneratedOnAdd()
.HasColumnType("time")
.HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0));
.HasColumnType("time");
b.Property<string>("Extension")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Height")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Width")
@@ -91,53 +71,51 @@ namespace tagvid.Migrations
b.HasKey("Id");
b.ToTable("VideoMetadatas");
b.ToTable("Videos");
});
modelBuilder.Entity("TagVideo", b =>
modelBuilder.Entity("Db.Models.VideoTag", b =>
{
b.Property<string>("TagsName")
b.Property<string>("VideoId")
.HasColumnType("nvarchar(450)");
b.Property<string>("VideosId")
b.Property<string>("TagId")
.HasColumnType("nvarchar(450)");
b.HasKey("TagsName", "VideosId");
b.HasKey("VideoId", "TagId");
b.HasIndex("VideosId");
b.HasIndex("TagId");
b.ToTable("VideoTags", (string)null);
b.ToTable("VideoTags");
});
modelBuilder.Entity("Db.Models.VideoMetadata", b =>
modelBuilder.Entity("Db.Models.VideoTag", b =>
{
b.HasOne("Db.Models.Video", "Video")
.WithOne("Metadata")
.HasForeignKey("Db.Models.VideoMetadata", "Id")
b.HasOne("Db.Models.Tag", "Tag")
.WithMany("VideoTags")
.HasForeignKey("TagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Db.Models.Video", "Video")
.WithMany("VideoTags")
.HasForeignKey("VideoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tag");
b.Navigation("Video");
});
modelBuilder.Entity("TagVideo", b =>
modelBuilder.Entity("Db.Models.Tag", b =>
{
b.HasOne("Db.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Db.Models.Video", null)
.WithMany()
.HasForeignKey("VideosId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("VideoTags");
});
modelBuilder.Entity("Db.Models.Video", b =>
{
b.Navigation("Metadata");
b.Navigation("VideoTags");
});
#pragma warning restore 612, 618
}

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace tagvid.Migrations
namespace TagVid.Migrations
{
/// <inheritdoc />
public partial class InitialMigration : Migration
@@ -15,7 +15,7 @@ namespace tagvid.Migrations
name: "Settings",
columns: table => new
{
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<int>(type: "int", nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
@@ -39,74 +39,45 @@ namespace tagvid.Migrations
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
RelativePath = table.Column<string>(type: "nvarchar(max)", nullable: false)
Title = table.Column<string>(type: "nvarchar(max)", nullable: true),
Duration = table.Column<TimeSpan>(type: "time", nullable: false),
Width = table.Column<int>(type: "int", nullable: false),
Height = table.Column<int>(type: "int", nullable: false),
Extension = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Videos", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VideoMetadatas",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
Title = table.Column<string>(type: "nvarchar(max)", nullable: false),
Duration = table.Column<TimeSpan>(type: "time", nullable: false, defaultValue: new TimeSpan(0, 0, 0, 0, 0)),
Width = table.Column<int>(type: "int", nullable: false),
Height = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VideoMetadatas", x => x.Id);
table.ForeignKey(
name: "FK_VideoMetadatas_Videos_Id",
column: x => x.Id,
principalTable: "Videos",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VideoTags",
columns: table => new
{
TagsName = table.Column<string>(type: "nvarchar(450)", nullable: false),
VideosId = table.Column<string>(type: "nvarchar(450)", nullable: false)
VideoId = table.Column<string>(type: "nvarchar(450)", nullable: false),
TagId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VideoTags", x => new { x.TagsName, x.VideosId });
table.PrimaryKey("PK_VideoTags", x => new { x.VideoId, x.TagId });
table.ForeignKey(
name: "FK_VideoTags_Tags_TagsName",
column: x => x.TagsName,
name: "FK_VideoTags_Tags_TagId",
column: x => x.TagId,
principalTable: "Tags",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VideoTags_Videos_VideosId",
column: x => x.VideosId,
name: "FK_VideoTags_Videos_VideoId",
column: x => x.VideoId,
principalTable: "Videos",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Settings_Name",
table: "Settings",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Tags_Name",
table: "Tags",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VideoTags_VideosId",
name: "IX_VideoTags_TagId",
table: "VideoTags",
column: "VideosId");
column: "TagId");
}
/// <inheritdoc />
@@ -115,9 +86,6 @@ namespace tagvid.Migrations
migrationBuilder.DropTable(
name: "Settings");
migrationBuilder.DropTable(
name: "VideoMetadatas");
migrationBuilder.DropTable(
name: "VideoTags");

View File

@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace tagvid.Migrations
namespace TagVid.Migrations
{
[DbContext(typeof(AdapterContext))]
partial class AdapterContextModelSnapshot : ModelSnapshot
@@ -23,8 +23,8 @@ namespace tagvid.Migrations
modelBuilder.Entity("Db.Models.Setting", b =>
{
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<int>("Name")
.HasColumnType("int");
b.Property<string>("Value")
.IsRequired()
@@ -32,9 +32,6 @@ namespace tagvid.Migrations
b.HasKey("Name");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Settings");
});
@@ -45,42 +42,25 @@ namespace tagvid.Migrations
b.HasKey("Name");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("Db.Models.Video", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("nvarchar(450)");
b.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Videos");
});
modelBuilder.Entity("Db.Models.VideoMetadata", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<TimeSpan>("Duration")
.ValueGeneratedOnAdd()
.HasColumnType("time")
.HasDefaultValue(new TimeSpan(0, 0, 0, 0, 0));
.HasColumnType("time");
b.Property<string>("Extension")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Height")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Width")
@@ -88,53 +68,51 @@ namespace tagvid.Migrations
b.HasKey("Id");
b.ToTable("VideoMetadatas");
b.ToTable("Videos");
});
modelBuilder.Entity("TagVideo", b =>
modelBuilder.Entity("Db.Models.VideoTag", b =>
{
b.Property<string>("TagsName")
b.Property<string>("VideoId")
.HasColumnType("nvarchar(450)");
b.Property<string>("VideosId")
b.Property<string>("TagId")
.HasColumnType("nvarchar(450)");
b.HasKey("TagsName", "VideosId");
b.HasKey("VideoId", "TagId");
b.HasIndex("VideosId");
b.HasIndex("TagId");
b.ToTable("VideoTags", (string)null);
b.ToTable("VideoTags");
});
modelBuilder.Entity("Db.Models.VideoMetadata", b =>
modelBuilder.Entity("Db.Models.VideoTag", b =>
{
b.HasOne("Db.Models.Video", "Video")
.WithOne("Metadata")
.HasForeignKey("Db.Models.VideoMetadata", "Id")
b.HasOne("Db.Models.Tag", "Tag")
.WithMany("VideoTags")
.HasForeignKey("TagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Db.Models.Video", "Video")
.WithMany("VideoTags")
.HasForeignKey("VideoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tag");
b.Navigation("Video");
});
modelBuilder.Entity("TagVideo", b =>
modelBuilder.Entity("Db.Models.Tag", b =>
{
b.HasOne("Db.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Db.Models.Video", null)
.WithMany()
.HasForeignKey("VideosId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("VideoTags");
});
modelBuilder.Entity("Db.Models.Video", b =>
{
b.Navigation("Metadata");
b.Navigation("VideoTags");
});
#pragma warning restore 612, 618
}

View File

@@ -0,0 +1,8 @@
namespace TagVid.Models;
public class DetailsViewModel
{
public string VideoId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public List<string> Tags { get; set; } = new List<string>();
}

View File

@@ -1,4 +1,4 @@
namespace tagvid.Models;
namespace TagVid.Models;
public class ErrorViewModel
{

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace TagVid.Models;
public class SettingsViewModel
{
[Required(ErrorMessage = "Video path is required.")]
[Display(Name = "Video Path")]
public string? VideoPath { get; set; } = null;
public string? ErrorMessage { get; set; }
}

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Http.Features;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -5,6 +6,17 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllersWithViews();
// increase the default upload size limit
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = long.MaxValue; // Set to a very high value
});
builder.WebHost.ConfigureKestrel(serverOptions =>
{
serverOptions.Limits.MaxRequestBodySize = long.MaxValue; // Set to a very high value
});
// Configure Entity Framework Core with SQL Server
builder.Services.AddDbContext<AdapterContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

70
Views/Home/Details.cshtml Normal file
View File

@@ -0,0 +1,70 @@
@model DetailsViewModel
@using (Html.BeginForm("Details", "Home", FormMethod.Post )) {
@Html.AntiForgeryToken()
@Html.HiddenFor(m => m.VideoId)
<div class="mb-3">
@Html.LabelFor(m => m.Title)
@Html.TextBoxFor(m => m.Title, new { @class = "form-control", @required = "required" })
@Html.ValidationMessageFor(m => m.Title)
</div>
<div class="mb-3">
@Html.LabelFor(m => m.Tags)
@foreach (var tag in Model.Tags)
{
<span class="badge rounded-pill text-bg-secondary" data-tag-name="@tag">
@Html.DisplayFor(m => tag)
<button type="button" class="btn-close btn-sm" aria-label="Close" onclick="removeTag('@tag')"></button>
</span>
}
<input type="text" id="newTag" class="form-control mt-2" placeholder="Add new tag" />
@for (int i = 0; i < Model.Tags.Count; i++)
{
<input type="hidden" class="hiddenTag" name="Tags[@i]" value="@Model.Tags.ElementAt(i)" />
}
</div>
<button type="submit" class="btn btn-primary">Update Video</button>
}
@section Scripts {
<script>
$(document).ready(function() {
$('#newTag').on('keypress', function(e) {
if (e.which === 13) { // Enter key
e.preventDefault();
var tagName = $(this).val().trim();
if (tagName) {
$(this).val(''); // Clear input
// check if a hidden input for this tag already exists
var existingTag = $(`input[value='${tagName}']`);
if (existingTag.length > 0) {
return;
}
var tagCount = $('.hiddenTag').length;
// Add the tag to a new hidden input
var hiddenInput = `<input type="hidden" class="hiddenTag" name="Tags[${tagCount}]" value="${tagName}" />`;
var tagHtml = `<span class="badge rounded-pill text-bg-secondary" data-tag-name="${tagName}">
${tagName}
<button type="button" class="btn-close btn-sm" aria-label="Close" onclick="removeTag('${tagName}')"></button>
</span>`;
$(this).after(hiddenInput);
$(this).before(tagHtml);
}
}
});
window.removeTag = function(tagName) {
// Remove the hidden input for the tag
$(`input[value='${tagName}']`).remove();
// Remove the badge from the UI
$(`span[data-tag-name='${tagName}']`).remove();
};
});
</script>
}

1
Views/Home/Index.cshtml Normal file
View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,15 @@
@model SettingsViewModel
@using (Html.BeginForm("Settings", "Home", FormMethod.Post)) {
@Html.AntiForgeryToken()
<h2>Settings</h2>
<div class="mb-3">
@Html.LabelFor(m => m.VideoPath, new { @class = "form-label" })
@Html.TextBoxFor(m => m.VideoPath, new { @class = "form-control" })
@Html.ValidationMessageFor(m => m.VideoPath, "", new { @class = "text-danger" })
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - tagvid</title>
<title>Tagvid</title>
<script type="importmap"></script>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
@@ -24,9 +24,14 @@
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Settings">Settings</a>
</li>
</ul>
<form class="d-flex" role="upload" method="post" asp-controller="Home" asp-action="Upload" enctype="multipart/form-data">
<input type="file" class="form-control me-2" id="videoFileInput" name="videoFile" required accept="video/*" />
<input type="hidden" name="previousUrl" value="@Context.Request.Path" />
<button type="submit" class="btn btn-outline-success">Upload</button>
</form>
</div>
</div>
</nav>
@@ -37,11 +42,6 @@
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2025 - tagvid - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>

View File

@@ -0,0 +1,4 @@
@using TagVid
@using TagVid.Models
@using Db.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

13
appsettings.json Normal file
View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=192.168.178.36;Database=TagVid;User Id=sa;Password=TngxW9Xp8QxXyb3GMxYz; Integrated Security=False; trustServerCertificate=true; Encrypt=False; MultipleActiveResultSets=True; Connection Timeout=30;"
}
}

View File

@@ -5,5 +5,6 @@ public class Tag
// We use the name of the tag as the Id to ensure uniqueness
public required string Name { get; set; }
public ICollection<Video> Videos { get; set; } = [];
public ICollection<VideoTag> VideoTags { get; set; } = [];
}

View File

@@ -1,10 +1,6 @@
namespace Db.Models;
/// <summary>
/// Represents metadata for a video, such as title, duration, resolution, etc.
/// So we dont need to load the video file to get this information.
/// </summary>
public class VideoMetadata
public class Video
{
// Disable warning as Id is required but not set in the constructor.
// This is to ensure that the Id is set by the database when the entity is added
@@ -12,9 +8,12 @@ public class VideoMetadata
public string Id { get; set; }
#pragma warning restore CS8618
public required string Title { get; set; }
public string? Title { get; set; }
public TimeSpan Duration { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public Video? Video { get; set; }
public string Extension { get; set; } = string.Empty; // File extension of the video file
public ICollection<VideoTag> VideoTags { get; set; } = [];
}

10
db/models/VideoTag.cs Normal file
View File

@@ -0,0 +1,10 @@
namespace Db.Models;
public class VideoTag
{
public string VideoId { get; set; }
public Video Video { get; set; }
public string TagId { get; set; }
public Tag Tag { get; set; }
}

60
helpers/FFProbeHelper.cs Normal file
View File

@@ -0,0 +1,60 @@
using System.Diagnostics;
using System.Text.Json;
namespace TagVid.Helpers;
public class VideoMetadata
{
public int Width { get; set; }
public int Height { get; set; }
public TimeSpan Duration { get; set; }
}
public class FFProbeHelper<T> where T : class
{
private static ILogger<T> _logger;
public FFProbeHelper(ILogger<T> logger)
{
_logger = logger;
}
public async Task<VideoMetadata?> GetVideoMetadataAsync(string filePath)
{
var startInfo = new ProcessStartInfo
{
FileName = "ffprobe",
Arguments = $"-v quiet -print_format json -show_streams \"{filePath}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
process.Start();
string output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
using var jsonDoc = JsonDocument.Parse(output);
var streams = jsonDoc.RootElement.GetProperty("streams");
var videoStream = streams.EnumerateArray()
.FirstOrDefault(s => s.GetProperty("codec_type").GetString() == "video");
if (videoStream.ValueKind == JsonValueKind.Undefined)
return null;
videoStream.TryGetProperty("duration", out var durEl1);
_logger.LogInformation($"{videoStream.GetProperty("width")}x{videoStream.GetProperty("height")}, Duration: {durEl1}");
var metadata = new VideoMetadata
{
Width = videoStream.GetProperty("width").GetInt32(),
Height = videoStream.GetProperty("height").GetInt32(),
Duration = durEl1.ValueKind == JsonValueKind.String
? TimeSpan.FromSeconds(double.Parse(durEl1.GetString() ?? "0"))
: TimeSpan.FromSeconds(durEl1.GetDouble())
};
return metadata;
}
}

View File

@@ -1,48 +1,24 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tagvid", "tagvid\tagvid.csproj", "{89456151-48D7-47F2-96AE-B27E62947059}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "adapter", "adapter\adapter.csproj", "{505FCE94-EFFC-436D-BC39-315F7D793475}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "tagvid", "tagvid.csproj", "{147974D8-F7CC-E7DF-9998-54200E639E4B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{89456151-48D7-47F2-96AE-B27E62947059}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Debug|Any CPU.Build.0 = Debug|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Debug|x64.ActiveCfg = Debug|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Debug|x64.Build.0 = Debug|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Debug|x86.ActiveCfg = Debug|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Debug|x86.Build.0 = Debug|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Release|Any CPU.ActiveCfg = Release|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Release|Any CPU.Build.0 = Release|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Release|x64.ActiveCfg = Release|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Release|x64.Build.0 = Release|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Release|x86.ActiveCfg = Release|Any CPU
{89456151-48D7-47F2-96AE-B27E62947059}.Release|x86.Build.0 = Release|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Debug|Any CPU.Build.0 = Debug|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Debug|x64.ActiveCfg = Debug|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Debug|x64.Build.0 = Debug|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Debug|x86.ActiveCfg = Debug|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Debug|x86.Build.0 = Debug|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Release|Any CPU.ActiveCfg = Release|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Release|Any CPU.Build.0 = Release|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Release|x64.ActiveCfg = Release|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Release|x64.Build.0 = Release|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Release|x86.ActiveCfg = Release|Any CPU
{505FCE94-EFFC-436D-BC39-315F7D793475}.Release|x86.Build.0 = Release|Any CPU
{147974D8-F7CC-E7DF-9998-54200E639E4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{147974D8-F7CC-E7DF-9998-54200E639E4B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{147974D8-F7CC-E7DF-9998-54200E639E4B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{147974D8-F7CC-E7DF-9998-54200E639E4B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BB1808E1-0EBB-4D0E-9A3C-0B133B761E43}
EndGlobalSection
EndGlobal

View File

@@ -1,138 +0,0 @@
using Db.Models;
using Microsoft.EntityFrameworkCore;
public class AdapterContext : DbContext
{
public AdapterContext(DbContextOptions<AdapterContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure your entity mappings here
base.OnModelCreating(modelBuilder);
// primary keys
modelBuilder.Entity<Tag>().HasKey(t => t.Name);
modelBuilder.Entity<Video>().HasKey(v => v.Id);
modelBuilder.Entity<VideoMetadata>().HasKey(vm => vm.Id);
modelBuilder.Entity<Setting>().HasKey(s => s.Name);
// Id of VideoMetadata is the same as Video Id
modelBuilder.Entity<VideoMetadata>()
.Property(vm => vm.Id)
.ValueGeneratedNever(); // Id is set by the Video entity
// Id of Video is set by the database
modelBuilder.Entity<Video>()
.Property(v => v.Id)
.ValueGeneratedOnAdd(); // Id is generated by the database
// Id of Setting is the same as SettingName enum value
modelBuilder.Entity<Setting>()
.Property(s => s.Name)
.HasConversion<string>(); // Store enum as string in the database
// relationships
// Video can have one Metadata (can be null)
modelBuilder.Entity<Video>()
.HasOne(v => v.Metadata)
.WithOne(vm => vm.Video)
.HasForeignKey<VideoMetadata>(vm => vm.Id);
// Video can have many Tags
modelBuilder.Entity<Video>()
.HasMany(v => v.Tags)
.WithMany(t => t.Videos)
.UsingEntity(j => j.ToTable("VideoTags")); // Join table for many-to-many relationship
// Setting has a unique Name
modelBuilder.Entity<Setting>()
.HasIndex(s => s.Name)
.IsUnique();
// Setting name enum is stored as string
modelBuilder.Entity<Setting>()
.Property(s => s.Name)
.HasConversion<string>();
// Setting value is stored as string
modelBuilder.Entity<Setting>()
.Property(s => s.Value)
.IsRequired();
// Video relative path is required
modelBuilder.Entity<Video>()
.Property(v => v.RelativePath)
.IsRequired();
// VideoMetadata properties
modelBuilder.Entity<VideoMetadata>()
.Property(vm => vm.Title)
.IsRequired();
modelBuilder.Entity<VideoMetadata>()
.Property(vm => vm.Duration)
.IsRequired();
// VideoMetadata duration is defaulted to zero if not set
modelBuilder.Entity<VideoMetadata>()
.Property(vm => vm.Duration)
.HasDefaultValue(TimeSpan.Zero);
// VideoMetadata width and height are required
modelBuilder.Entity<VideoMetadata>()
.Property(vm => vm.Width)
.IsRequired();
modelBuilder.Entity<VideoMetadata>()
.Property(vm => vm.Height)
.IsRequired();
// Tag name is required
modelBuilder.Entity<Tag>()
.Property(t => t.Name)
.IsRequired();
// Tag name is unique
modelBuilder.Entity<Tag>()
.HasIndex(t => t.Name)
.IsUnique();
}
// Tags a video can have (e.g. "funny", "cat", "dog")
public DbSet<Tag> Tags { get; set; }
// Videos with tags, relative paths, and other metadata
public DbSet<Video> Videos { get; set; }
// Video metadata, such as title, description, and duration
public DbSet<VideoMetadata> VideoMetadatas { get; set; }
// Settings for the adapter, such as video path
public DbSet<Setting> Settings { get; set; }
public async Task<T?> GetSettingAsync<T>(SettingName settingName)
{
var setting = await Settings.FindAsync(settingName);
return setting != null ? (T?)Convert.ChangeType(setting.Value, typeof(T)) : default;
}
public async Task SetSettingAsync<T>(SettingName settingName, T value)
{
var setting = await Settings.FindAsync(settingName);
if (setting == null)
{
setting = new Setting { Name = settingName, Value = value?.ToString() ?? string.Empty };
Settings.Add(setting);
}
else
{
setting.Value = value?.ToString() ?? string.Empty;
Settings.Update(setting);
}
await SaveChangesAsync();
}
}

View File

@@ -1,31 +0,0 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using tagvid.Models;
namespace tagvid.Controllers;
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
public HomeController(ILogger<HomeController> logger)
{
_logger = logger;
}
public IActionResult Index()
{
return View();
}
public IActionResult Privacy()
{
return View();
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}

View File

@@ -1,8 +0,0 @@
@{
ViewData["Title"] = "Home Page";
}
<div class="text-center">
<h1 class="display-4">Welcome</h1>
<p>Learn about <a href="https://learn.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
</div>

View File

@@ -1,3 +0,0 @@
@using tagvid
@using tagvid.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,15 +0,0 @@
namespace Db.Models;
public class Video
{
// Disable warning as Id is required but not set in the constructor.
// This is to ensure that the Id is set by the database when the entity is added
#pragma warning disable CS8618
public string Id { get; set; }
#pragma warning restore CS8618
public required string RelativePath { get; set; }
public ICollection<Tag> Tags { get; set; } = [];
public VideoMetadata? Metadata { get; set; }
}

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB