init: repo

This commit is contained in:
jackfiled 2024-07-04 15:27:19 +08:00
commit a90302f7ba
293 changed files with 18994 additions and 0 deletions

210
Chiara/.editorconfig Normal file
View File

@ -0,0 +1,210 @@
# editorconfig.org
# top-most EditorConfig file
root = true
# Default settings:
# A newline ending every file
# Use 4 spaces as indentation
[*]
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[project.json]
indent_size = 2
# C# and Visual Basic files
[*.{cs,vb}]
charset = utf-8-bom
# Analyzers
dotnet_analyzer_diagnostic.category-Security.severity = error
dotnet_code_quality.ca1802.api_surface = private, internal
# Miscellaneous style rules
dotnet_sort_system_directives_first = true
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
# avoid this. unless absolutely necessary
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_qualification_for_event = false:suggestion
# name all constant fields using PascalCase
dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields
dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style
dotnet_naming_symbols.constant_fields.applicable_kinds = field
dotnet_naming_symbols.constant_fields.required_modifiers = const
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
# static fields should have s_ prefix
dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion
dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields
dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style
dotnet_naming_symbols.static_fields.applicable_kinds = field
dotnet_naming_symbols.static_fields.required_modifiers = static
dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected
dotnet_naming_style.static_prefix_style.required_prefix = s_
dotnet_naming_style.static_prefix_style.capitalization = camel_case
# internal and private fields should be _camelCase
dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion
dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields
dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style
dotnet_naming_symbols.private_internal_fields.applicable_kinds = field
dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal
dotnet_naming_style.camel_case_underscore_style.required_prefix = _
dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case
# Code quality
dotnet_style_readonly_field = true:suggestion
dotnet_code_quality_unused_parameters = non_public:suggestion
# Expression-level preferences
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:refactoring
dotnet_style_prefer_conditional_expression_over_return = true:refactoring
# CA2208: Instantiate argument exceptions correctly
dotnet_diagnostic.CA2208.severity = error
# C# files
[*.cs]
# New line preferences
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_switch_labels = true
csharp_indent_labels = one_less_than_current
# Modifier preferences
csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
# Code style defaults
csharp_using_directive_placement = outside_namespace:suggestion
csharp_prefer_braces = true:refactoring
csharp_preserve_single_line_blocks = true:none
csharp_preserve_single_line_statements = false:none
csharp_prefer_static_local_function = true:suggestion
csharp_prefer_simple_using_statement = false:none
csharp_style_prefer_switch_expression = true:suggestion
# Expression-bodied members
csharp_style_expression_bodied_methods = true:refactoring
csharp_style_expression_bodied_constructors = true:refactoring
csharp_style_expression_bodied_operators = true:refactoring
csharp_style_expression_bodied_properties = true:refactoring
csharp_style_expression_bodied_indexers = true:refactoring
csharp_style_expression_bodied_accessors = true:refactoring
csharp_style_expression_bodied_lambdas = true:refactoring
csharp_style_expression_bodied_local_functions = true:refactoring
# Pattern matching
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
# Null checking preferences
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Other features
csharp_style_prefer_index_operator = false:none
csharp_style_prefer_range_operator = false:none
csharp_style_pattern_local_over_anonymous_function = false:none
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = do_not_ignore
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Namespace preference
csharp_style_namespace_declarations = file_scoped:suggestion
# Types: use keywords instead of BCL types, and permit var only when the type is clear
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = false:none
csharp_style_var_elsewhere = false:suggestion
# Visual Basic files
[*.vb]
# Modifier preferences
visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion
# C++ Files
[*.{cpp,h,in}]
curly_bracket_next_line = true
indent_brace_style = Allman
# Xml project files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}]
indent_size = 2
# Xml build files
[*.builds]
indent_size = 2
# Xml files
[*.{xml,stylecop,resx,ruleset}]
indent_size = 2
# Xml config files
[*.{props,targets,config,nuspec}]
indent_size = 2
# Shell scripts
[*.sh]
end_of_line = lf
[*.{cmd, bat}]
end_of_line = crlf
# Markdown files
[*.md]
# Double trailing spaces can be used for BR tags, and other instances are enforced by Markdownlint
trim_trailing_whitespace = false

View File

@ -0,0 +1,26 @@
name: Build Docker Image
on:
push:
branches:
- master
jobs:
Build-Martina:
runs-on: archlinux
steps:
- uses: https://git.rrricardo.top/actions/checkout@v4
name: Check out code
- name: Cache nuget packages
uses: https://git.rrricardo.top/actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget
save-always: true
- name: Publish dotnet app
run: |
cd Chiara
dotnet publish
- name: Build docker image
run: |
cd Chiara
docker build . -t chiara:latest

484
Chiara/.gitignore vendored Normal file
View File

@ -0,0 +1,484 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View File

@ -0,0 +1,96 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp;
/// <summary>
/// A library capable of parsing Anime filenames
/// This code is a C++ to C# port of <see href="https://github.com/erengy/anitomy">Anitomy</see>>,
/// using the already existing Java port <see href="https://github.com/Vorror/anitomyJ">AnitomyJ</see> as a reference.
/// </summary>
public class Anitomy
{
private Anitomy() {}
/// <summary>
/// Parses an anime <see cref="filename"/> into its constituent elements.
/// </summary>
/// <param name="filename">the anime file name</param>
/// <param name="options">the options to parse with, use Parse(filename) to use default options</param>
/// <returns>the list of parsed elements</returns>
public static IEnumerable<Element> Parse(string filename, Options options)
{
List<Element> elements = [];
List<Token> tokens = [];
if (options.ParseFileExtension)
{
string extension = "";
if (RemoveExtensionFromFilename(ref filename, ref extension))
{
elements.Add(new Element(ElementCategory.ElementFileExtension, extension));
}
}
if (string.IsNullOrEmpty(filename))
{
return elements;
}
elements.Add(new Element(ElementCategory.ElementFileName, filename));
bool isTokenized = new Tokenizer(filename, elements, options, tokens).Tokenize();
if (!isTokenized)
{
return elements;
}
new Parser(elements, options, tokens).Parse();
return elements;
}
/// <summary>
/// Parses an anime <see cref="filename"/> into its consituent elements.
/// </summary>
/// <param name="filename">the anime file name</param>
/// <returns>the list of parsed elements</returns>
public static IEnumerable<Element> Parse(string filename)
{
return Parse(filename, new Options());
}
/// <summary>
/// Removes the extension from the <see cref="filename"/>
/// </summary>
/// <param name="filename">the ref that will be updated with the new filename</param>
/// <param name="extension">the ref that will be updated with the file extension</param>
/// <returns>if the extension was successfully separated from the filename</returns>
private static bool RemoveExtensionFromFilename(ref string filename, ref string extension)
{
int position;
if (string.IsNullOrEmpty(filename) || (position = filename.LastIndexOf('.')) == -1)
{
return false;
}
extension = filename[(position + 1)..];
if (extension.Length > 4 || !extension.All(char.IsLetterOrDigit))
{
return false;
}
string keyword = KeywordManager.Normalize(extension);
if (!KeywordManager.Contains(ElementCategory.ElementFileExtension, keyword))
{
return false;
}
filename = filename[..position];
return true;
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,90 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp;
/// <summary>
/// An <see cref="Element"/> represents an identified Anime <see cref="Token"/>.
/// A single filename may contain multiple of the same
/// token(e.g <see cref="ElementCategory.ElementEpisodeNumber"/>).
/// </summary>
public class Element
{
public ElementCategory Category { get; set; }
public string Value { get; }
/// <summary>
/// Constructs a new Element
/// </summary>
/// <param name="category">the category of the element</param>
/// <param name="value">the element's value</param>
public Element(ElementCategory category, string value)
{
Category = category;
Value = value;
}
public override int GetHashCode()
{
return -1926371015 + Value.GetHashCode();
}
public override bool Equals(object? obj)
{
if (this == obj)
{
return true;
}
if (obj == null || GetType() != obj.GetType())
{
return false;
}
var other = (Element) obj;
return Category.Equals(other.Category);
}
public override string ToString()
{
return $"Element{{category={Category}, value='{Value}'}}";
}
}
/** Element Categories */
public enum ElementCategory
{
ElementAnimeSeason,
ElementAnimeSeasonPrefix,
ElementAnimeTitle,
ElementAnimeType,
ElementAnimeYear,
ElementAudioTerm,
ElementDeviceCompatibility,
ElementEpisodeNumber,
ElementEpisodeNumberAlt,
ElementEpisodePrefix,
ElementEpisodeTitle,
ElementFileChecksum,
ElementFileExtension,
ElementFileName,
ElementLanguage,
ElementOther,
ElementReleaseGroup,
ElementReleaseInformation,
ElementReleaseVersion,
ElementSource,
ElementSubtitles,
ElementVideoResolution,
ElementVideoTerm,
ElementVolumeNumber,
ElementVolumePrefix,
ElementUnknown
}

View File

@ -0,0 +1,279 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp;
/// <summary>
/// A class to manager the list of known anime keywords. This class is analogous to <code>keyword.cpp</code> of Anitomy, and <code>KeywordManager.java</code> of AnitomyJ
/// </summary>
public static class KeywordManager
{
private static readonly Dictionary<string, Keyword> s_keys = new();
private static readonly Dictionary<string, Keyword> s_extensions = new();
private static readonly List<Tuple<ElementCategory, List<string>>> s_peekEntries;
static KeywordManager()
{
var optionsDefault = new KeywordOptions();
var optionsInvalid = new KeywordOptions(true, true, false);
var optionsUnidentifiable = new KeywordOptions(false, true, true);
var optionsUnidentifiableInvalid = new KeywordOptions(false, true, false);
var optionsUnidentifiableUnsearchable = new KeywordOptions(false, false, true);
Add(ElementCategory.ElementAnimeSeasonPrefix,
optionsUnidentifiable,
new List<string> {"SAISON", "SEASON"});
Add(ElementCategory.ElementAnimeType,
optionsUnidentifiable,
new List<string> {"GEKIJOUBAN", "MOVIE", "OAD", "OAV", "ONA", "OVA", "SPECIAL", "SPECIALS", "TV"});
Add(ElementCategory.ElementAnimeType,
optionsUnidentifiableUnsearchable,
new List<string> {"SP"}); // e.g. "Yumeiro Patissiere SP Professional"
Add(ElementCategory.ElementAnimeType,
optionsUnidentifiableInvalid,
new List<string> {"ED", "ENDING", "NCED", "NCOP", "OP", "OPENING", "PREVIEW", "PV"});
Add(ElementCategory.ElementAudioTerm,
optionsDefault,
new List<string> {
// Audio channels
"2.0CH", "2CH", "5.1", "5.1CH", "DTS", "DTS-ES", "DTS5.1",
"TRUEHD5.1",
// Audio codec
"AAC", "AACX2", "AACX3", "AACX4", "AC3", "EAC3", "E-AC-3",
"FLAC", "FLACX2", "FLACX3", "FLACX4", "LOSSLESS", "MP3", "OGG", "VORBIS",
// Audio language
"DUALAUDIO", "DUAL AUDIO"
});
Add(ElementCategory.ElementDeviceCompatibility,
optionsDefault,
new List<string> {"IPAD3", "IPHONE5", "IPOD", "PS3", "XBOX", "XBOX360"});
Add(ElementCategory.ElementDeviceCompatibility,
optionsUnidentifiable,
new List<string> {"ANDROID"});
Add(ElementCategory.ElementEpisodePrefix,
optionsDefault,
new List<string> {"EP", "EP.", "EPS", "EPS.", "EPISODE", "EPISODE.", "EPISODES", "CAPITULO", "EPISODIO", "FOLGE"});
Add(ElementCategory.ElementEpisodePrefix,
optionsInvalid,
new List<string> {"E", "\\x7B2C"}); // single-letter episode keywords are not valid tokens
Add(ElementCategory.ElementFileExtension,
optionsDefault,
new List<string> {"3GP", "AVI", "DIVX", "FLV", "M2TS", "MKV", "MOV", "MP4", "MPG", "OGM", "RM", "RMVB", "TS", "WEBM", "WMV"});
Add(ElementCategory.ElementFileExtension,
optionsInvalid,
new List<string> {"AAC", "AIFF", "FLAC", "M4A", "MP3", "MKA", "OGG", "WAV", "WMA", "7Z", "RAR", "ZIP", "ASS", "SRT"});
Add(ElementCategory.ElementLanguage,
optionsDefault,
new List<string> {"ENG", "ENGLISH", "ESPANO", "JAP", "PT-BR", "SPANISH", "VOSTFR"});
Add(ElementCategory.ElementLanguage,
optionsUnidentifiable,
new List<string> {"ESP", "ITA"}); // e.g. "Tokyo ESP:, "Bokura ga Ita"
Add(ElementCategory.ElementOther,
optionsDefault,
new List<string> {"REMASTER", "REMASTERED", "UNCENSORED", "UNCUT", "TS", "VFR", "WIDESCREEN", "WS"});
Add(ElementCategory.ElementReleaseGroup,
optionsDefault,
new List<string> {"THORA"});
Add(ElementCategory.ElementReleaseInformation,
optionsDefault,
new List<string> {"BATCH", "COMPLETE", "PATCH", "REMUX"});
Add(ElementCategory.ElementReleaseInformation,
optionsUnidentifiable,
new List<string> {"END", "FINAL"}); // e.g. "The End of Evangelion", 'Final Approach"
Add(ElementCategory.ElementReleaseVersion,
optionsDefault,
new List<string> {"V0", "V1", "V2", "V3", "V4"});
Add(ElementCategory.ElementSource,
optionsDefault,
new List<string> {"BD", "BDRIP", "BLURAY", "BLU-RAY", "DVD", "DVD5", "DVD9", "DVD-R2J", "DVDRIP", "DVD-RIP", "R2DVD", "R2J", "R2JDVD", "R2JDVDRIP", "HDTV", "HDTVRIP", "TVRIP", "TV-RIP", "WEBCAST", "WEBRIP"});
Add(ElementCategory.ElementSubtitles,
optionsDefault,
new List<string> {"ASS", "BIG5", "DUB", "DUBBED", "HARDSUB", "HARDSUBS", "RAW", "SOFTSUB", "SOFTSUBS", "SUB", "SUBBED", "SUBTITLED"});
Add(ElementCategory.ElementVideoTerm,
optionsDefault,
new List<string> {
// Frame rate
"23.976FPS", "24FPS", "29.97FPS", "30FPS", "60FPS", "120FPS",
// Video codec
"8BIT", "8-BIT", "10BIT", "10BITS", "10-BIT", "10-BITS",
"HI10", "HI10P", "HI444", "HI444P", "HI444PP",
"H264", "H265", "H.264", "H.265", "X264", "X265", "X.264",
"AVC", "HEVC", "HEVC2", "DIVX", "DIVX5", "DIVX6", "XVID",
// Video format
"AVI", "RMVB", "WMV", "WMV3", "WMV9",
// Video quality
"HQ", "LQ",
// Video resolution
"HD", "SD"});
Add(ElementCategory.ElementVolumePrefix,
optionsDefault,
new List<string> {"VOL", "VOL.", "VOLUME"});
s_peekEntries = new List<Tuple<ElementCategory, List<string>>>
{
Tuple.Create(ElementCategory.ElementAudioTerm, new List<string> { "Dual Audio" }),
Tuple.Create(ElementCategory.ElementVideoTerm, new List<string> { "H264", "H.264", "h264", "h.264" }),
Tuple.Create(ElementCategory.ElementVideoResolution, new List<string> { "480p", "720p", "1080p" }),
Tuple.Create(ElementCategory.ElementSource, new List<string> { "Blu-Ray" })
};
}
public static string Normalize(string word)
{
return string.IsNullOrEmpty(word) ? word : word.ToUpperInvariant();
}
public static bool Contains(ElementCategory category, string keyword)
{
var keys = GetKeywordContainer(category);
if (keys.TryGetValue(keyword, out var foundEntry))
{
return foundEntry.Category == category;
}
return false;
}
/// <summary>
/// Finds a particular <code>keyword</code>. If found sets <code>category</code> and <code>options</code> to the found search result.
/// </summary>
/// <param name="keyword">the keyword to search for</param>
/// <param name="category">the reference that will be set/changed to the found keyword category</param>
/// <param name="options">the reference that will be set/changed to the found keyword options</param>
/// <returns>if the keyword was found</returns>
public static bool FindAndSet(string keyword, ref ElementCategory category, ref KeywordOptions options)
{
Dictionary<string, Keyword> keys = GetKeywordContainer(category);
if (!keys.TryGetValue(keyword, out var foundEntry))
{
return false;
}
if (category == ElementCategory.ElementUnknown)
{
category = foundEntry.Category;
}
else if (foundEntry.Category != category)
{
return false;
}
options = foundEntry.Options;
return true;
}
/// <summary>
/// Given a particular <code>filename</code> and <code>range</code> attempt to preidentify the token before we attempt the main parsing logic
/// </summary>
/// <param name="filename">the filename</param>
/// <param name="range">the search range</param>
/// <param name="elements">elements array that any pre-identified elements will be added to</param>
/// <param name="preIdentifiedTokens">elements array that any pre-identified token ranges will be added to</param>
public static void PeekAndAdd(string filename, TokenRange range, List<Element> elements, List<TokenRange> preIdentifiedTokens)
{
int endR = range.Offset + range.Size;
string search = filename.Substring(range.Offset, endR > filename.Length ? filename.Length - range.Offset : endR - range.Offset);
foreach (var entry in s_peekEntries)
{
foreach (string keyword in entry.Item2)
{
int foundIdx = search.IndexOf(keyword, StringComparison.CurrentCulture);
if (foundIdx == -1) continue;
foundIdx += range.Offset;
elements.Add(new Element(entry.Item1, keyword));
preIdentifiedTokens.Add(new TokenRange(foundIdx, keyword.Length));
}
}
}
// Private API
/** Returns the appropriate keyword container. */
private static Dictionary<string, Keyword> GetKeywordContainer(ElementCategory category)
{
return category == ElementCategory.ElementFileExtension ? s_extensions : s_keys;
}
/// Adds a <code>category</code>, <code>options</code>, and <code>keywords</code> to the internal keywords list.
private static void Add(ElementCategory category, KeywordOptions options, IEnumerable<string> keywords)
{
var keys = GetKeywordContainer(category);
foreach (string key in keywords.Where(k => !string.IsNullOrEmpty(k) && !keys.ContainsKey(k)))
{
keys[key] = new Keyword(category, options);
}
}
}
/// <summary>
/// Keyword options for a particular keyword.
/// </summary>
public class KeywordOptions
{
public bool Identifiable { get; }
public bool Searchable { get; }
public bool Valid { get; }
public KeywordOptions() : this(true, true, true) {}
/// <summary>
/// Constructs a new keyword options
/// </summary>
/// <param name="identifiable">if the token is identifiable</param>
/// <param name="searchable">if the token is searchable</param>
/// <param name="valid">if the token is valid</param>
public KeywordOptions(bool identifiable, bool searchable, bool valid)
{
Identifiable = identifiable;
Searchable = searchable;
Valid = valid;
}
}
/// <summary>
/// A Keyword
/// </summary>
public struct Keyword
{
public readonly ElementCategory Category;
public readonly KeywordOptions Options;
/// <summary>
/// Constructs a new Keyword
/// </summary>
/// <param name="category">the category of the keyword</param>
/// <param name="options">the keyword's options</param>
public Keyword(ElementCategory category, KeywordOptions options)
{
Category = category;
Options = options;
}
}

View File

@ -0,0 +1,28 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp;
/// <summary>
/// AnitomySharp search configuration options
/// </summary>
public class Options(
string delimiters = " _.&+,|",
bool episode = true,
bool title = true,
bool extension = true,
bool group = true)
{
public string AllowedDelimiters { get; } = delimiters;
public bool ParseEpisodeNumber { get; } = episode;
public bool ParseEpisodeTitle { get; } = title;
public bool ParseFileExtension { get; } = extension;
public bool ParseReleaseGroup { get; } = group;
}

View File

@ -0,0 +1,430 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp;
/// <summary>
/// Class to classify <see cref="Token"/>s
/// </summary>
public class Parser
{
public bool IsEpisodeKeywordsFound { get; private set; }
public ParserHelper ParseHelper { get; }
public ParserNumber ParseNumber { get; }
public List<Element> Elements { get; }
public List<Token> Tokens { get; }
private Options Options { get; }
/// <summary>
/// Constructs a new token parser
/// </summary>
/// <param name="elements">the list where parsed elements will be added</param>
/// <param name="options">the parser options</param>
/// <param name="tokens">the list of tokens</param>
public Parser(List<Element> elements, Options options, List<Token> tokens)
{
Elements = elements;
Options = options;
Tokens = tokens;
ParseHelper = new ParserHelper(this);
ParseNumber = new ParserNumber(this);
}
/** Begins the parsing process */
public bool Parse()
{
SearchForKeywords();
SearchForIsolatedNumbers();
if (Options.ParseEpisodeNumber)
{
SearchForEpisodeNumber();
}
SearchForAnimeTitle();
if (Options.ParseReleaseGroup && Empty(ElementCategory.ElementReleaseGroup))
{
SearchForReleaseGroup();
}
if (Options.ParseEpisodeTitle && !Empty(ElementCategory.ElementEpisodeNumber))
{
SearchForEpisodeTitle();
}
ValidateElements();
return Empty(ElementCategory.ElementAnimeTitle);
}
/** Search for anime keywords. */
private void SearchForKeywords()
{
for (int i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
if (token.Category != Token.TokenCategory.Unknown) continue;
string word = token.Content;
word = word.Trim(" -".ToCharArray());
if (string.IsNullOrEmpty(word)) continue;
// Don't bother if the word is a number that cannot be CRC
if (word.Length != 8 && StringHelper.IsNumericString(word)) continue;
string keyword = KeywordManager.Normalize(word);
var category = ElementCategory.ElementUnknown;
var options = new KeywordOptions();
if (KeywordManager.FindAndSet(keyword, ref category, ref options))
{
if (!Options.ParseReleaseGroup && category == ElementCategory.ElementReleaseGroup) continue;
if (!ParserHelper.IsElementCategorySearchable(category) || !options.Searchable) continue;
if (ParserHelper.IsElementCategorySingular(category) && !Empty(category)) continue;
switch (category)
{
case ElementCategory.ElementAnimeSeasonPrefix:
ParseHelper.CheckAndSetAnimeSeasonKeyword(token, i);
continue;
case ElementCategory.ElementEpisodePrefix when options.Valid:
ParseHelper.CheckExtentKeyword(ElementCategory.ElementEpisodeNumber, i, token);
continue;
case ElementCategory.ElementReleaseVersion:
word = word[1..];
break;
case ElementCategory.ElementVolumePrefix:
ParseHelper.CheckExtentKeyword(ElementCategory.ElementVolumeNumber, i, token);
continue;
}
}
else
{
if (Empty(ElementCategory.ElementFileChecksum) && ParserHelper.IsCrc32(word))
{
category = ElementCategory.ElementFileChecksum;
}
else if (Empty(ElementCategory.ElementVideoResolution) && ParserHelper.IsResolution(word))
{
category = ElementCategory.ElementVideoResolution;
}
}
if (category == ElementCategory.ElementUnknown) continue;
Elements.Add(new Element(category, word));
if (options.Identifiable)
{
token.Category = Token.TokenCategory.Identifier;
}
}
}
/** Search for episode number. */
private void SearchForEpisodeNumber()
{
// List all unknown tokens that contain a number
var tokens = new List<int>();
for (int i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
if (token.Category == Token.TokenCategory.Unknown &&
ParserHelper.IndexOfFirstDigit(token.Content) != -1)
{
tokens.Add(i);
}
}
if (tokens.Count == 0) return;
IsEpisodeKeywordsFound = !Empty(ElementCategory.ElementEpisodeNumber);
// If a token matches a known episode pattern, it has to be the episode number
if (ParseNumber.SearchForEpisodePatterns(tokens)) return;
// We have previously found an episode number via keywords
if (!Empty(ElementCategory.ElementEpisodeNumber)) return;
// From now on, we're only interested in numeric tokens
tokens.RemoveAll(r => !StringHelper.IsNumericString(Tokens[r].Content));
// e.g. "01 (176)", "29 (04)"
if (ParseNumber.SearchForEquivalentNumbers(tokens)) return;
// e.g. " - 08"
if (ParseNumber.SearchForSeparatedNumbers(tokens)) return;
// "e.g. "[12]", "(2006)"
if (ParseNumber.SearchForIsolatedNumbers(tokens)) return;
// Consider using the last number as a last resort
ParseNumber.SearchForLastNumber(tokens);
}
/// <summary>
/// Search for anime title
/// </summary>
private void SearchForAnimeTitle()
{
bool enclosedTitle = false;
int tokenBegin = Token.FindToken(Tokens, 0, Tokens.Count, Token.TokenFlag.FlagNotEnclosed,
Token.TokenFlag.FlagUnknown);
// If that doesn't work, find the first unknown token in the second enclosed
// group, assuming that the first one is the release group
if (!Token.InListRange(tokenBegin, Tokens))
{
tokenBegin = 0;
enclosedTitle = true;
bool skippedPreviousGroup = false;
do
{
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagUnknown);
if (!Token.InListRange(tokenBegin, Tokens)) break;
// Ignore groups that are composed of non-Latin characters
if (StringHelper.IsMostlyLatinString(Tokens[tokenBegin].Content) && skippedPreviousGroup)
{
break;
}
// Get the first unknown token of the next group
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket);
tokenBegin = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagUnknown);
skippedPreviousGroup = true;
} while (Token.InListRange(tokenBegin, Tokens));
}
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until an identifier (or a bracket, if the title is enclosed) is found
int tokenEnd = Token.FindToken(
Tokens,
tokenBegin,
Tokens.Count,
Token.TokenFlag.FlagIdentifier,
enclosedTitle ? Token.TokenFlag.FlagBracket : Token.TokenFlag.FlagNone);
// If within the interval there's an open bracket without its matching pair,
// move the upper endpoint back to the bracket
if (!enclosedTitle)
{
int lastBracket = tokenEnd;
bool bracketOpen = false;
for (int i = tokenBegin; i < tokenEnd; i++)
{
if (Tokens[i].Category != Token.TokenCategory.Bracket) continue;
lastBracket = i;
bracketOpen = !bracketOpen;
}
if (bracketOpen) tokenEnd = lastBracket;
}
// If the interval ends with an enclosed group (e.g. "Anime Title [Fansub]"),
// move the upper endpoint back to the beginning of the group. We ignore
// parentheses in order to keep certain groups (e.g. "(TV)") intact.
if (!enclosedTitle)
{
int token = Token.FindPrevToken(Tokens, tokenEnd, Token.TokenFlag.FlagNotDelimiter);
while (ParseHelper.IsTokenCategory(token, Token.TokenCategory.Bracket) && Tokens[token].Content[0] != ')')
{
token = Token.FindPrevToken(Tokens, token, Token.TokenFlag.FlagBracket);
if (!Token.InListRange(token, Tokens)) continue;
tokenEnd = token;
token = Token.FindPrevToken(Tokens, tokenEnd, Token.TokenFlag.FlagNotDelimiter);
}
}
ParseHelper.BuildElement(ElementCategory.ElementAnimeTitle, false,
Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
}
/// <summary>
/// Search for release group
/// </summary>
private void SearchForReleaseGroup()
{
for (int tokenBegin = 0, tokenEnd = tokenBegin; tokenBegin < Tokens.Count;)
{
// Find the first enclosed unknown token
tokenBegin = Token.FindToken(Tokens, tokenEnd, Tokens.Count, Token.TokenFlag.FlagEnclosed,
Token.TokenFlag.FlagUnknown);
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until a bracket or identifier is found
tokenEnd = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket,
Token.TokenFlag.FlagIdentifier);
if (!Token.InListRange(tokenEnd, Tokens) ||
Tokens[tokenEnd].Category != Token.TokenCategory.Bracket) continue;
// Ignore if it's not the first non-delimiter token in group
int prevToken = Token.FindPrevToken(Tokens, tokenBegin, Token.TokenFlag.FlagNotDelimiter);
if (Token.InListRange(prevToken, Tokens) &&
Tokens[prevToken].Category != Token.TokenCategory.Bracket) continue;
ParseHelper.BuildElement(ElementCategory.ElementReleaseGroup, true,
Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
return;
}
}
/// <summary>
/// Search for episode title
/// </summary>
private void SearchForEpisodeTitle()
{
int tokenBegin;
int tokenEnd = 0;
do
{
// Find the first non-enclosed unknown token
tokenBegin = Token.FindToken(Tokens, tokenEnd, Tokens.Count, Token.TokenFlag.FlagNotEnclosed,
Token.TokenFlag.FlagUnknown);
if (!Token.InListRange(tokenBegin, Tokens)) return;
// Continue until a bracket or identifier is found
tokenEnd = Token.FindToken(Tokens, tokenBegin, Tokens.Count, Token.TokenFlag.FlagBracket,
Token.TokenFlag.FlagIdentifier);
// Ignore if it's only a dash
if (tokenEnd - tokenBegin <= 2 && ParserHelper.IsDashCharacter(Tokens[tokenBegin].Content[0])) continue;
//if (tokenBegin.Pos == null || tokenEnd.Pos == null) continue;
ParseHelper.BuildElement(ElementCategory.ElementEpisodeTitle, false,
Tokens.GetRange(tokenBegin, tokenEnd - tokenBegin));
return;
} while (Token.InListRange(tokenBegin, Tokens));
}
/// <summary>
/// Search for isolated numbers
/// </summary>
private void SearchForIsolatedNumbers()
{
for (int i = 0; i < Tokens.Count; i++)
{
var token = Tokens[i];
if (token.Category != Token.TokenCategory.Unknown || !StringHelper.IsNumericString(token.Content) ||
!ParseHelper.IsTokenIsolated(i))
{
continue;
}
int number = StringHelper.StringToInt(token.Content);
// Anime year
if (number >= ParserNumber.AnimeYearMin && number <= ParserNumber.AnimeYearMax)
{
if (Empty(ElementCategory.ElementAnimeYear))
{
Elements.Add(new Element(ElementCategory.ElementAnimeYear, token.Content));
token.Category = Token.TokenCategory.Identifier;
continue;
}
}
// Video resolution
if (number != 480 && number != 720 && number != 1080) continue;
// If these numbers are isolated, it's more likely for them to be the
// video resolution rather than the episode number. Some fansub groups use these without the "p" suffix.
if (!Empty(ElementCategory.ElementVideoResolution)) continue;
Elements.Add(new Element(ElementCategory.ElementVideoResolution, token.Content));
token.Category = Token.TokenCategory.Identifier;
}
}
/// <summary>
/// Validate Elements
/// </summary>
private void ValidateElements()
{
if (Empty(ElementCategory.ElementAnimeType) || Empty(ElementCategory.ElementEpisodeTitle))
{
return;
}
string episodeTitle = Get(ElementCategory.ElementEpisodeTitle);
for (int i = 0; i < Elements.Count;)
{
var el = Elements[i];
if (el.Category == ElementCategory.ElementAnimeType)
{
if (episodeTitle.Contains(el.Value))
{
if (episodeTitle.Length == el.Value.Length)
{
Elements.RemoveAll(element =>
element.Category == ElementCategory.ElementEpisodeTitle); // invalid episode title
}
else
{
string keyword = KeywordManager.Normalize(el.Value);
if (KeywordManager.Contains(ElementCategory.ElementAnimeType, keyword))
{
i = Erase(el); // invalid anime type
continue;
}
}
}
}
++i;
}
}
/// <summary>
/// Returns whether or not the parser contains this category
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
private bool Empty(ElementCategory category)
{
return Elements.All(element => element.Category != category);
}
/// <summary>
/// Returns the value of a particular category
/// </summary>
/// <param name="category"></param>
/// <returns></returns>
private string Get(ElementCategory category)
{
var foundElement = Elements.Find(element => element.Category == category);
if (foundElement != null) return foundElement.Value;
Element e = new Element(category, "");
Elements.Add(e);
foundElement = e;
return foundElement.Value;
}
/// <summary>
/// Deletes the first element with the same <code>element.Category</code> and returns the deleted element's position.
/// </summary>
private int Erase(Element element)
{
int removedIdx = -1;
for (int i = 0; i < Elements.Count; i++)
{
var currentElement = Elements[i];
if (element.Category != currentElement.Category) continue;
removedIdx = i;
Elements.RemoveAt(i);
break;
}
return removedIdx;
}
}

View File

@ -0,0 +1,281 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System.Text;
namespace AnitomySharp;
/// <summary>
/// Utility class to assist in the parsing.
/// </summary>
public class ParserHelper(Parser parser)
{
private static readonly System.Buffers.SearchValues<char> s_myChars = System.Buffers.SearchValues.Create("xX\u00D7");
private const string Dashes = "-\u2010\u2011\u2012\u2013\u2014\u2015";
private const string DashesWithSpace = " -\u2010\u2011\u2012\u2013\u2014\u2015";
private static readonly Dictionary<string, string> s_ordinals = new()
{
{"1st", "1"}, {"First", "1"},
{"2nd", "2"}, {"Second", "2"},
{"3rd", "3"}, {"Third", "3"},
{"4th", "4"}, {"Fourth", "4"},
{"5th", "5"}, {"Fifth", "5"},
{"6th", "6"}, {"Sixth", "6"},
{"7th", "7"}, {"Seventh", "7"},
{"8th", "8"}, {"Eighth", "8"},
{"9th", "9"}, {"Ninth", "9"}
};
/// <summary>
/// Returns whether or not the <code>result</code> matches the <code>category</code>.
/// </summary>
public bool IsTokenCategory(int result, Token.TokenCategory category)
{
return Token.InListRange(result, parser.Tokens) && parser.Tokens[result].Category == category;
}
/// <summary>
/// Returns whether or not the <code>str</code> is a CRC string.
/// </summary>
public static bool IsCrc32(string str)
{
return str.Length == 8 && StringHelper.IsHexadecimalString(str);
}
/// <summary>
/// Returns whether or not the <code>character</code> is a dash character
/// </summary>
public static bool IsDashCharacter(char c)
{
return Dashes.Contains(c.ToString());
}
/// <summary>
/// Returns a number from an original (e.g. 2nd)
/// </summary>
private static string GetNumberFromOrdinal(string str)
{
return string.IsNullOrEmpty(str) ? string.Empty : s_ordinals.GetValueOrDefault(str, "");
}
/// <summary>
/// Returns the index of the first digit in the <code>str</code>; -1 otherwise.
/// </summary>
public static int IndexOfFirstDigit(string str)
{
if (string.IsNullOrEmpty(str)) return -1;
for (int i = 0; i < str.Length; i++)
{
if (char.IsDigit(str, i))
{
return i;
}
}
return -1;
}
/// <summary>
/// Returns whether or not the <code>str</code> is a resolution.
/// </summary>
public static bool IsResolution(string str)
{
if (string.IsNullOrEmpty(str)) return false;
const int minWidthSize = 3;
const int minHeightSize = 3;
switch (str.Length)
{
case >= minWidthSize + 1 + minHeightSize:
{
int pos = str.AsSpan().IndexOfAny(s_myChars);
if (pos == -1 || pos < minWidthSize || pos > str.Length - (minHeightSize + 1)) return false;
return !str.Where((t, i) => i != pos && !char.IsDigit(t)).Any();
}
case < minHeightSize + 1:
return false;
default:
{
if (char.ToLower(str[^1]) != 'p') return false;
for (int i = 0; i < str.Length - 1; i++)
{
if (!char.IsDigit(str[i])) return false;
}
return true;
}
}
}
/// <summary>
/// Returns whether or not the <code>category</code> is searchable.
/// </summary>
public static bool IsElementCategorySearchable(ElementCategory category)
{
return category switch
{
ElementCategory.ElementAnimeSeasonPrefix or ElementCategory.ElementAnimeType
or ElementCategory.ElementAudioTerm or ElementCategory.ElementDeviceCompatibility
or ElementCategory.ElementEpisodePrefix or ElementCategory.ElementFileChecksum
or ElementCategory.ElementLanguage or ElementCategory.ElementOther
or ElementCategory.ElementReleaseGroup or ElementCategory.ElementReleaseInformation
or ElementCategory.ElementReleaseVersion or ElementCategory.ElementSource
or ElementCategory.ElementSubtitles or ElementCategory.ElementVideoResolution
or ElementCategory.ElementVideoTerm or ElementCategory.ElementVolumePrefix => true,
_ => false
};
}
/// <summary>
/// Returns whether the <code>category</code> is singular.
/// </summary>
public static bool IsElementCategorySingular(ElementCategory category)
{
return category switch
{
ElementCategory.ElementAnimeSeason or ElementCategory.ElementAnimeType or ElementCategory.ElementAudioTerm
or ElementCategory.ElementDeviceCompatibility or ElementCategory.ElementEpisodeNumber
or ElementCategory.ElementLanguage or ElementCategory.ElementOther
or ElementCategory.ElementReleaseInformation or ElementCategory.ElementSource
or ElementCategory.ElementVideoTerm => false,
_ => false
};
}
/// <summary>
/// Returns whether or not a token at the current <code>pos</code> is isolated(surrounded by braces).
/// </summary>
public bool IsTokenIsolated(int pos)
{
int prevToken = Token.FindPrevToken(parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
if (!IsTokenCategory(prevToken, Token.TokenCategory.Bracket)) return false;
int nextToken = Token.FindNextToken(parser.Tokens, pos, Token.TokenFlag.FlagNotDelimiter);
return IsTokenCategory(nextToken, Token.TokenCategory.Bracket);
}
/// <summary>
/// Finds and sets the anime season keyword.
/// </summary>
public bool CheckAndSetAnimeSeasonKeyword(Token token, int currentTokenPos)
{
int previousToken = Token.FindPrevToken(parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (Token.InListRange(previousToken, parser.Tokens))
{
string number = GetNumberFromOrdinal(parser.Tokens[previousToken].Content);
if (!string.IsNullOrEmpty(number))
{
SetAnimeSeason(parser.Tokens[previousToken], token, number);
return true;
}
}
int nextToken = Token.FindNextToken(parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(nextToken, parser.Tokens) ||
!StringHelper.IsNumericString(parser.Tokens[nextToken].Content)) return false;
SetAnimeSeason(token, parser.Tokens[nextToken], parser.Tokens[nextToken].Content);
return true;
void SetAnimeSeason(Token first, Token second, string content)
{
parser.Elements.Add(new Element(ElementCategory.ElementAnimeSeason, content));
first.Category = Token.TokenCategory.Identifier;
second.Category = Token.TokenCategory.Identifier;
}
}
/// <summary>
/// A Method to find the correct volume/episode number when prefixed (i.e. Vol.4).
/// </summary>
/// <param name="category">the category we're searching for</param>
/// <param name="currentTokenPos">the current token position</param>
/// <param name="token">the token</param>
/// <returns>true if we found the volume/episode number</returns>
public bool CheckExtentKeyword(ElementCategory category, int currentTokenPos, Token token)
{
int nToken = Token.FindNextToken(parser.Tokens, currentTokenPos, Token.TokenFlag.FlagNotDelimiter);
if (!IsTokenCategory(nToken, Token.TokenCategory.Unknown)) return false;
if (IndexOfFirstDigit(parser.Tokens[nToken].Content) != 0) return false;
switch (category)
{
case ElementCategory.ElementEpisodeNumber:
if (!parser.ParseNumber.MatchEpisodePatterns(parser.Tokens[nToken].Content, parser.Tokens[nToken]))
{
parser.ParseNumber.SetEpisodeNumber(parser.Tokens[nToken].Content, parser.Tokens[nToken], false);
}
break;
case ElementCategory.ElementVolumeNumber:
if (!parser.ParseNumber.MatchVolumePatterns(parser.Tokens[nToken].Content, parser.Tokens[nToken]))
{
parser.ParseNumber.SetVolumeNumber(parser.Tokens[nToken].Content, parser.Tokens[nToken], false);
}
break;
}
token.Category = Token.TokenCategory.Identifier;
return true;
}
public void BuildElement(ElementCategory category, bool keepDelimiters, List<Token> tokens)
{
var element = new StringBuilder();
for (int i = 0; i < tokens.Count; i++)
{
var token = tokens[i];
switch (token.Category)
{
case Token.TokenCategory.Unknown:
element.Append(token.Content);
token.Category = Token.TokenCategory.Identifier;
break;
case Token.TokenCategory.Bracket:
element.Append(token.Content);
break;
case Token.TokenCategory.Delimiter:
string delimiter = "";
if (!string.IsNullOrEmpty(token.Content))
{
delimiter = token.Content[0].ToString();
}
if (keepDelimiters)
{
element.Append(delimiter);
}
else if (Token.InListRange(i, tokens))
{
switch (delimiter)
{
case ",":
case "&":
element.Append(delimiter);
break;
default:
element.Append(' ');
break;
}
}
break;
}
}
if (!keepDelimiters)
{
element = new StringBuilder(element.ToString().Trim(DashesWithSpace.ToCharArray()));
}
if (!string.IsNullOrEmpty(element.ToString()))
{
parser.Elements.Add(new Element(category, element.ToString()));
}
}
}

View File

@ -0,0 +1,681 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System.Text.RegularExpressions;
namespace AnitomySharp;
/// <summary>
/// A utility class to assist in number parsing.
/// </summary>
public partial class ParserNumber(Parser parser)
{
public const int AnimeYearMin = 1900;
public const int AnimeYearMax = 2100;
private const int EpisodeNumberMax = AnimeYearMax - 1;
private const int VolumeNumberMax = 50;
private const string RegexMatchOnlyStart = @"\A(?:";
private const string RegexMatchOnlyEnd = @")\z";
/// <summary>
/// Returns whether or not the <code>number</code> is a volume number
/// </summary>
private static bool IsValidVolumeNumber(string number)
{
return StringHelper.StringToInt(number) <= VolumeNumberMax;
}
/// <summary>
/// Returns whether or not the <code>number</code> is a valid episode number.
/// </summary>
private static bool IsValidEpisodeNumber(string number)
{
// Eliminate non numeric portion of number, then parse as double.
string temp = "";
for (int i = 0; i < number.Length && char.IsDigit(number[i]); i++)
{
temp += number[i];
}
return !string.IsNullOrEmpty(temp) && double.Parse(temp) <= EpisodeNumberMax;
}
/// <summary>
/// Sets the alternative episode number.
/// </summary>
private void SetAlternativeEpisodeNumber(string number, Token token)
{
parser.Elements.Add(new Element(ElementCategory.ElementEpisodeNumberAlt, number));
token.Category = Token.TokenCategory.Identifier;
}
/// <summary>
/// Sets the volume number.
/// </summary>
/// <param name="number">the number</param>
/// <param name="token">the token which contains the volume number</param>
/// <param name="validate">true if we should check if it's a valid number, false to disable verification</param>
/// <returns>true if the volume number was set</returns>
public bool SetVolumeNumber(string number, Token token, bool validate)
{
if (validate && !IsValidVolumeNumber(number)) return false;
parser.Elements.Add(new Element(ElementCategory.ElementVolumeNumber, number));
token.Category = Token.TokenCategory.Identifier;
return true;
}
/// <summary>
/// Sets the anime episode number.
/// </summary>
/// <param name="number">the episode number</param>
/// <param name="token">the token which contains the volume number</param>
/// <param name="validate">true if we should check if it's a valid episode number; false to disable validation</param>
/// <returns>true if the episode number was set</returns>
public bool SetEpisodeNumber(string number, Token token, bool validate)
{
if (validate && !IsValidEpisodeNumber(number)) return false;
token.Category = Token.TokenCategory.Identifier;
var category = ElementCategory.ElementEpisodeNumber;
if (parser.IsEpisodeKeywordsFound)
{
foreach (var element in parser.Elements)
{
if (element.Category != ElementCategory.ElementEpisodeNumber) continue;
int comparison = StringHelper.StringToInt(number) - StringHelper.StringToInt(element.Value);
switch (comparison)
{
case > 0:
category = ElementCategory.ElementEpisodeNumberAlt;
break;
case < 0:
element.Category = ElementCategory.ElementEpisodeNumberAlt;
break;
default:
return false;
}
break;
}
}
parser.Elements.Add(new Element(category, number));
return true;
}
/// <summary>
/// Checks if a number follows the specified <code>token</code>
/// </summary>
/// <param name="category">the category to set if a number follows the <code>token</code></param>
/// <param name="token">the token</param>
/// <returns>true if a number follows the token; false otherwise</returns>
private bool NumberComesAfterPrefix(ElementCategory category, Token token)
{
int numberBegin = ParserHelper.IndexOfFirstDigit(token.Content);
string prefix = StringHelper.SubstringWithCheck(token.Content, 0, numberBegin).ToUpperInvariant();
if (!KeywordManager.Contains(category, prefix)) return false;
string number = StringHelper.SubstringWithCheck(token.Content, numberBegin, token.Content.Length - numberBegin);
switch (category)
{
case ElementCategory.ElementEpisodePrefix:
if (!MatchEpisodePatterns(number, token))
{
SetEpisodeNumber(number, token, false);
}
return true;
case ElementCategory.ElementVolumePrefix:
if (!MatchVolumePatterns(number, token))
{
SetVolumeNumber(number, token, false);
}
return true;
default:
return false;
}
}
/// <summary>
/// Checks whether the number precedes the word "of"
/// </summary>
/// <param name="token">the token</param>
/// <param name="currentTokenIdx">the index of the token</param>
/// <returns>true if the token precedes the word "of"</returns>
private bool NumberComesBeforeAnotherNumber(Token token, int currentTokenIdx)
{
int separatorToken = Token.FindNextToken(parser.Tokens, currentTokenIdx, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(separatorToken, parser.Tokens)) return false;
var separators = new List<Tuple<string, bool>>
{
Tuple.Create("&", true),
Tuple.Create("of", false)
};
foreach (var separator in separators)
{
if (parser.Tokens[separatorToken].Content != separator.Item1) continue;
int otherToken = Token.FindNextToken(parser.Tokens, separatorToken, Token.TokenFlag.FlagNotDelimiter);
if (!Token.InListRange(otherToken, parser.Tokens)
|| !StringHelper.IsNumericString(parser.Tokens[otherToken].Content)) continue;
SetEpisodeNumber(token.Content, token, false);
if (separator.Item2)
{
SetEpisodeNumber(parser.Tokens[otherToken].Content, parser.Tokens[otherToken], false);
}
parser.Tokens[separatorToken].Category = Token.TokenCategory.Identifier;
parser.Tokens[otherToken].Category = Token.TokenCategory.Identifier;
return true;
}
return false;
}
// EPISODE MATCHERS
/// <summary>
/// Attempts to find an episode/season inside a <code>word</code>
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the word was matched to an episode/season number</returns>
public bool MatchEpisodePatterns(string word, Token token)
{
if (StringHelper.IsNumericString(word)) return false;
word = word.Trim(" -".ToCharArray());
bool numericFront = char.IsDigit(word[0]);
bool numericBack = char.IsDigit(word[^1]);
if (numericFront && numericBack)
{
// e.g. "01v2"
if (MatchSingleEpisodePattern(word, token))
{
return true;
}
// e.g. "01-02", "03-05v2"
if (MatchMultiEpisodePattern(word, token))
{
return true;
}
// e.g. "07.5"
if (MatchFractionalEpisodePattern(word, token))
{
return true;
}
}
if (numericBack)
{
// e.g. "2x01", "S01E03", "S01-02xE001-150"
if (MatchSeasonAndEpisodePattern(word, token))
{
return true;
}
// e.g. "#01", "#02-03v2"
if (MatchNumberSignPattern(word, token))
{
return true;
}
}
switch (numericFront)
{
// e.g. "ED1", "OP4a", "OVA2"
case false when MatchTypeAndEpisodePattern(word, token):
// e.g. "4a", "111C"
case true when !numericBack && MatchPartialEpisodePattern(word, token):
return true;
default:
// U+8A71 is used as counter for stories, episodes of TV series, etc.
return numericFront && MatchJapaneseCounterPattern(word, token);
}
}
/// <summary>
/// Match a single episode pattern. e.g. "01v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSingleEpisodePattern(string word, Token token)
{
var match = SingleEpisodeRegex().Match(word);
if (!match.Success) return false;
SetEpisodeNumber(match.Groups[1].Value, token, false);
parser.Elements.Add(new Element(ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
return true;
}
/// <summary>
/// Match a multi episode pattern. e.g. "01-02", "03-05v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchMultiEpisodePattern(string word, Token token)
{
var match = MultiEpisodeRegex().Match(word);
if (!match.Success) return false;
string lowerBound = match.Groups[1].Value;
string upperBound = match.Groups[3].Value;
if (StringHelper.StringToInt(lowerBound) >= StringHelper.StringToInt(upperBound)) return false;
if (!SetEpisodeNumber(lowerBound, token, true)) return false;
SetEpisodeNumber(upperBound, token, true);
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
parser.Elements.Add(new Element(ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
}
if (!string.IsNullOrEmpty(match.Groups[4].Value))
{
parser.Elements.Add(new Element(ElementCategory.ElementReleaseVersion, match.Groups[4].Value));
}
return true;
}
/// <summary>
/// Match season and episode patterns. e.g. "2x01", "S01E03", "S01-02xE001-150".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSeasonAndEpisodePattern(string word, Token token)
{
var match = SeasonEpisodeRegex().Match(word);
if (!match.Success) return false;
parser.Elements.Add(new Element(ElementCategory.ElementAnimeSeason, match.Groups[1].Value));
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
parser.Elements.Add(new Element(ElementCategory.ElementAnimeSeason, match.Groups[2].Value));
}
SetEpisodeNumber(match.Groups[3].Value, token, false);
if (!string.IsNullOrEmpty(match.Groups[4].Value))
{
SetEpisodeNumber(match.Groups[4].Value, token, false);
}
return true;
}
/// <summary>
/// Match type and episode. e.g. "ED1", "OP4a", "OVA2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchTypeAndEpisodePattern(string word, Token token)
{
int numberBegin = ParserHelper.IndexOfFirstDigit(word);
string prefix = StringHelper.SubstringWithCheck(word, 0, numberBegin);
var category = ElementCategory.ElementAnimeType;
var options = new KeywordOptions();
if (!KeywordManager.FindAndSet(KeywordManager.Normalize(prefix), ref category, ref options)) return false;
parser.Elements.Add(new Element(ElementCategory.ElementAnimeType, prefix));
string number = word.Substring(numberBegin);
if (!MatchEpisodePatterns(number, token) && !SetEpisodeNumber(number, token, true)) return false;
int foundIdx = parser.Tokens.IndexOf(token);
if (foundIdx == -1) return true;
token.Content = number;
parser.Tokens.Insert(foundIdx,
new Token(options.Identifiable ? Token.TokenCategory.Identifier : Token.TokenCategory.Unknown, prefix, token.Enclosed));
return true;
}
/// <summary>
/// Match fractional episodes. e.g. "07.5"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchFractionalEpisodePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word))
{
word = "";
}
const string regexPattern = RegexMatchOnlyStart + @"\d+\.5" + RegexMatchOnlyEnd;
var match = Regex.Match(word, regexPattern);
return match.Success && SetEpisodeNumber(word, token, true);
}
/// <summary>
/// Match partial episodes. e.g. "4a", "111C".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchPartialEpisodePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) return false;
int foundIdx = Enumerable.Range(0, word.Length)
.DefaultIfEmpty(word.Length)
.FirstOrDefault(value => !char.IsDigit(word[value]));
int suffixLength = word.Length - foundIdx;
return suffixLength == 1 && IsValidSuffix(word[foundIdx]) && SetEpisodeNumber(word, token, true);
bool IsValidSuffix(int c) => c is >= 'A' and <= 'C' or >= 'a' and <= 'c';
}
/// <summary>
/// Match episodes with number signs. e.g. "#01", "#02-03v2"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchNumberSignPattern(string word, Token token)
{
if (string.IsNullOrEmpty(word) || word[0] != '#') word = "";
var match = NumberEpisodeRegex().Match(word);
if (!match.Success) return false;
if (!SetEpisodeNumber(match.Groups[1].Value, token, true)) return false;
if (!string.IsNullOrEmpty(match.Groups[2].Value))
{
SetEpisodeNumber(match.Groups[2].Value, token, false);
}
if (!string.IsNullOrEmpty(match.Groups[3].Value))
{
parser.Elements.Add(new Element(ElementCategory.ElementReleaseVersion, match.Groups[3].Value));
}
return true;
}
/// <summary>
/// Match Japanese patterns. e.g. U+8A71 is used as counter for stories, episodes of TV series, etc.
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchJapaneseCounterPattern(string word, Token token)
{
if (string.IsNullOrEmpty(word) || word[^1] != '\u8A71') return false;
var match = JapaneseEpisodeRegex().Match(word);
if (!match.Success) return false;
SetEpisodeNumber(match.Groups[1].Value, token, false);
return true;
}
// VOLUME MATCHES
/// <summary>
/// Attempts to find an episode/season inside a <code>word</code>
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the word was matched to an episode/season number</returns>
public bool MatchVolumePatterns(string word, Token token)
{
// All patterns contain at least one non-numeric character
if (StringHelper.IsNumericString(word)) return false;
word = word.Trim(" -".ToCharArray());
bool numericFront = char.IsDigit(word[0]);
bool numericBack = char.IsDigit(word[^1]);
if (numericFront && numericBack)
{
// e.g. "01v2" e.g. "01-02", "03-05v2"
return MatchSingleVolumePattern(word, token) || MatchMultiVolumePattern(word, token);
}
return false;
}
/// <summary>
/// Match single volume. e.g. "01v2"
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchSingleVolumePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) word = "";
var match = SingleVolumeRegex().Match(word);
if (!match.Success) return false;
SetVolumeNumber(match.Groups[1].Value, token, false);
parser.Elements.Add(new Element(ElementCategory.ElementReleaseVersion, match.Groups[2].Value));
return true;
}
/// <summary>
/// Match multi-volume. e.g. "01-02", "03-05v2".
/// </summary>
/// <param name="word">the word</param>
/// <param name="token">the token</param>
/// <returns>true if the token matched</returns>
private bool MatchMultiVolumePattern(string word, Token token)
{
if (string.IsNullOrEmpty(word)) word = "";
var match = MultiVolumeRegex().Match(word);
if (!match.Success) return false;
string lowerBound = match.Groups[1].Value;
string upperBound = match.Groups[2].Value;
if (StringHelper.StringToInt(lowerBound) >= StringHelper.StringToInt(upperBound)) return false;
if (!SetVolumeNumber(lowerBound, token, true)) return false;
SetVolumeNumber(upperBound, token, false);
if (string.IsNullOrEmpty(match.Groups[3].Value))
{
parser.Elements.Add(new Element(ElementCategory.ElementReleaseVersion, match.Groups[3].Value));
}
return true;
}
// SEARCH
/// <summary>
/// Searches for isolated numbers in a list of <code>tokens</code>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an isolated number was found</returns>
public bool SearchForIsolatedNumbers(IEnumerable<int> tokens)
{
return tokens
.Where(it => parser.Tokens[it].Enclosed && parser.ParseHelper.IsTokenIsolated(it))
.Any(it => SetEpisodeNumber(parser.Tokens[it].Content, parser.Tokens[it], true));
}
/// <summary>
/// Searches for separated numbers in a list of <code>tokens</code>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true fi a separated number was found</returns>
public bool SearchForSeparatedNumbers(List<int> tokens)
{
foreach (int it in tokens)
{
int previousToken = Token.FindPrevToken(parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
// See if the number has a preceding "-" separator
if (!parser.ParseHelper.IsTokenCategory(previousToken, Token.TokenCategory.Unknown)
|| !ParserHelper.IsDashCharacter(parser.Tokens[previousToken].Content[0])) continue;
if (!SetEpisodeNumber(parser.Tokens[it].Content, parser.Tokens[it], true)) continue;
parser.Tokens[previousToken].Category = Token.TokenCategory.Identifier;
return true;
}
return false;
}
/// <summary>
/// Searches for episode patterns in a list of <code>tokens</code>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an episode number was found</returns>
public bool SearchForEpisodePatterns(List<int> tokens)
{
foreach (int it in tokens)
{
bool numericFront = parser.Tokens[it].Content.Length > 0 && char.IsDigit(parser.Tokens[it].Content[0]);
if (!numericFront)
{
// e.g. "EP.1", "Vol.1"
if (NumberComesAfterPrefix(ElementCategory.ElementEpisodePrefix, parser.Tokens[it]))
{
return true;
}
if (NumberComesAfterPrefix(ElementCategory.ElementVolumePrefix, parser.Tokens[it]))
{
continue;
}
}
else
{
// e.g. "8 & 10", "01 of 24"
if (NumberComesBeforeAnotherNumber(parser.Tokens[it], it))
{
return true;
}
}
// Look for other patterns
if (MatchEpisodePatterns(parser.Tokens[it].Content, parser.Tokens[it]))
{
return true;
}
}
return false;
}
/// <summary>
/// Searches for equivalent number in a list of <code>tokens</code>. e.g. 08(114)
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if an equivalent number was found</returns>
public bool SearchForEquivalentNumbers(List<int> tokens)
{
foreach (int it in tokens)
{
// Find number must be isolated.
if (parser.ParseHelper.IsTokenIsolated(it) || !IsValidEpisodeNumber(parser.Tokens[it].Content))
{
continue;
}
// Find the first enclosed, non-delimiter token
int nextToken = Token.FindNextToken(parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
if (!parser.ParseHelper.IsTokenCategory(nextToken, Token.TokenCategory.Bracket)) continue;
nextToken = Token.FindNextToken(parser.Tokens, nextToken, Token.TokenFlag.FlagEnclosed,
Token.TokenFlag.FlagNotDelimiter);
if (!parser.ParseHelper.IsTokenCategory(nextToken, Token.TokenCategory.Unknown)) continue;
// Check if it's an isolated number
if (!parser.ParseHelper.IsTokenIsolated(nextToken)
|| !StringHelper.IsNumericString(parser.Tokens[nextToken].Content)
|| !IsValidEpisodeNumber(parser.Tokens[nextToken].Content))
{
continue;
}
var list = new List<Token>
{
parser.Tokens[it], parser.Tokens[nextToken]
};
list.Sort((o1, o2) => StringHelper.StringToInt(o1.Content) - StringHelper.StringToInt(o2.Content));
SetEpisodeNumber(list[0].Content, list[0], false);
SetAlternativeEpisodeNumber(list[1].Content, list[1]);
return true;
}
return false;
}
/// <summary>
/// Searches for the last number token in a list of <code>tokens</code>
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <returns>true if the last number token was found</returns>
public bool SearchForLastNumber(List<int> tokens)
{
for (int i = tokens.Count - 1; i >= 0; i--)
{
int it = tokens[i];
// Assuming that episode number always comes after the title,
// the first token cannot be what we're looking for
if (it == 0) continue;
if (parser.Tokens[it].Enclosed) continue;
// Ignore if it's the first non-enclosed, non-delimiter token
if (parser.Tokens.GetRange(0, it)
.All(r => r.Enclosed || r.Category == Token.TokenCategory.Delimiter))
{
continue;
}
int previousToken = Token.FindPrevToken(parser.Tokens, it, Token.TokenFlag.FlagNotDelimiter);
if (parser.ParseHelper.IsTokenCategory(previousToken, Token.TokenCategory.Unknown))
{
if (parser.Tokens[previousToken].Content.Equals("Movie", StringComparison.InvariantCultureIgnoreCase)
|| parser.Tokens[previousToken].Content.Equals("Part", StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
}
// We'll use this number after all
if (SetEpisodeNumber(parser.Tokens[it].Content, parser.Tokens[it], true))
{
return true;
}
}
return false;
}
[GeneratedRegex(@"\A(?:(\d{1,3})[vV](\d))\z")]
private static partial Regex SingleEpisodeRegex();
[GeneratedRegex(@"\A(?:(\d{1,3})(?:[vV](\d))?[-~&+](\d{1,3})(?:[vV](\d))?)\z")]
private static partial Regex MultiEpisodeRegex();
[GeneratedRegex(@"\A(?:S?(\d{1,2})(?:-S?(\d{1,2}))?(?:x|[ ._-x]?E)(\d{1,3})(?:-E?(\d{1,3}))?)\z")]
private static partial Regex SeasonEpisodeRegex();
[GeneratedRegex(@"\A(?:#(\d{1,3})(?:[-~&+](\d{1,3}))?(?:[vV](\d))?)\z")]
private static partial Regex NumberEpisodeRegex();
[GeneratedRegex(@"\A(?:(\d{1,3})話)\z")]
private static partial Regex JapaneseEpisodeRegex();
[GeneratedRegex(@"\A(?:(\d{1,2})[vV](\d))\z")]
private static partial Regex SingleVolumeRegex();
[GeneratedRegex(@"\A(?:(\d{1,2})[-~&+](\d{1,2})(?:[vV](\d))?)\z")]
private static partial Regex MultiVolumeRegex();
}

View File

@ -0,0 +1,91 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp;
/// <summary>
/// A string helper class that is analogous to <code>string.cpp</code> of the original Anitomy, and <code>StringHelper.java</code> of AnitomyJ.
/// </summary>
public static class StringHelper
{
/// <summary>
/// Returns whether or not the character is alphanumeric
/// </summary>
public static bool IsAlphanumericChar(char c)
{
return c is >= '0' and <= '9' or >= 'A' and <= 'Z' or >= 'a' and <= 'z';
}
/// <summary>
/// Returns whether or not the character is a hex character.
/// </summary>
private static bool IsHexadecimalChar(char c)
{
return c is >= '0' and <= '9' or >= 'A' and <= 'F' or >= 'a' and <= 'f';
}
/// <summary>
/// Returns whether or not the character is a latin character
/// </summary>
private static bool IsLatinChar(char c)
{
// We're just checking until the end of the Latin Extended-B block,
// rather than all the blocks that belong to the Latin script.
return c <= '\u024F';
}
/// <summary>
/// Returns whether or not the <code>str</code> is a hex string.
/// </summary>
public static bool IsHexadecimalString(string str)
{
return !string.IsNullOrEmpty(str) && str.All(IsHexadecimalChar);
}
/// <summary>
/// Returns whether or not the <code>str</code> is mostly a latin string.
/// </summary>
public static bool IsMostlyLatinString(string str)
{
double length = !string.IsNullOrEmpty(str) ? 1.0 : str.Length;
return str.Where(IsLatinChar).Count() / length >= 0.5;
}
/// <summary>
/// Returns whether or not the <code>str</code> is a numeric string.
/// </summary>
public static bool IsNumericString(string str)
{
return str.All(char.IsDigit);
}
/// <summary>
/// Returns the int value of the <code>str</code>; 0 otherwise.
/// </summary>
public static int StringToInt(string str)
{
try
{
return int.Parse(str);
}
catch (Exception ex)
{
Console.WriteLine(ex);
return 0;
}
}
public static string SubstringWithCheck(string str, int start, int count)
{
if (start + count > str.Length) count = str.Length - start;
return str.Substring(start, count);
}
}

View File

@ -0,0 +1,226 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp;
/// <summary>
/// An anime filename is tokenized into individual <see cref="Token"/>s. This class represents an individual token.
/// </summary>
public class Token
{
/// <summary>
/// The category of the token.
/// </summary>
public enum TokenCategory
{
Unknown,
Bracket,
Delimiter,
Identifier,
Invalid
}
/// <summary>
/// TokenFlag, used for searching specific token categories. This allows granular searching of TokenCategories.
/// </summary>
public enum TokenFlag
{
// None
FlagNone,
// Categories
FlagBracket,
FlagNotBracket,
FlagDelimiter,
FlagNotDelimiter,
FlagIdentifier,
FlagNotIdentifier,
FlagUnknown,
FlagNotUnknown,
FlagValid,
FlagNotValid,
// Enclosed (Meaning that it is enclosed in some bracket (e.g. [ ] ))
FlagEnclosed,
FlagNotEnclosed
}
/// <summary>
/// Set of token category flags
/// </summary>
private static readonly List<TokenFlag> s_flagMaskCategories =
[
TokenFlag.FlagBracket, TokenFlag.FlagNotBracket,
TokenFlag.FlagDelimiter, TokenFlag.FlagNotDelimiter,
TokenFlag.FlagIdentifier, TokenFlag.FlagNotIdentifier,
TokenFlag.FlagUnknown, TokenFlag.FlagNotUnknown,
TokenFlag.FlagValid, TokenFlag.FlagNotValid
];
/// <summary>
/// Set of token enclosed flags
/// </summary>
private static readonly List<TokenFlag> s_flagMaskEnclosed = [TokenFlag.FlagEnclosed, TokenFlag.FlagNotEnclosed];
public TokenCategory Category { get; set; }
public string Content { get; set; }
public bool Enclosed { get; }
/// <summary>
/// Constructs a new token
/// </summary>
/// <param name="category">the token category</param>
/// <param name="content">the token content</param>
/// <param name="enclosed">whether or not the token is enclosed in braces</param>
public Token(TokenCategory category, string content, bool enclosed)
{
Category = category;
Content = content;
Enclosed = enclosed;
}
/// <summary>
/// Validates a token against the <code>flags</code>. The <code>flags</code> is used as a search parameter.
/// </summary>
/// <param name="token">the token</param>
/// <param name="flags">the flags the token must conform against</param>
/// <returns>true if the token conforms to the set of <code>flags</code>; false otherwise</returns>
private static bool CheckTokenFlags(Token token, ICollection<TokenFlag> flags)
{
// Make sure token is the correct closure
if (flags.Any(f => s_flagMaskEnclosed.Contains(f)))
{
bool success = CheckFlag(TokenFlag.FlagEnclosed) == token.Enclosed;
if (!success) return false; // Not enclosed correctly (e.g. enclosed when we're looking for non-enclosed).
}
// Make sure token is the correct category
if (!flags.Any(f => s_flagMaskCategories.Contains(f))) return true;
bool secondarySuccess = false;
CheckCategory(TokenFlag.FlagBracket, TokenFlag.FlagNotBracket, TokenCategory.Bracket);
CheckCategory(TokenFlag.FlagDelimiter, TokenFlag.FlagNotDelimiter, TokenCategory.Delimiter);
CheckCategory(TokenFlag.FlagIdentifier, TokenFlag.FlagNotIdentifier, TokenCategory.Identifier);
CheckCategory(TokenFlag.FlagUnknown, TokenFlag.FlagNotUnknown, TokenCategory.Unknown);
CheckCategory(TokenFlag.FlagNotValid, TokenFlag.FlagValid, TokenCategory.Invalid);
return secondarySuccess;
void CheckCategory(TokenFlag fe, TokenFlag fn, TokenCategory c)
{
if (secondarySuccess) return;
bool result = CheckFlag(fe) ? token.Category == c : CheckFlag(fn) && token.Category != c;
secondarySuccess = result;
}
// Simple alias to check if flag is a part of the set
bool CheckFlag(TokenFlag flag)
{
return flags.Contains(flag);
}
}
/// <summary>
/// Given a list of <code>tokens</code>, searches for any token token that matches the list of <code>flags</code>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="begin">the search starting position.</param>
/// <param name="end">the search ending position.</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindToken(List<Token> tokens, int begin, int end, params TokenFlag[] flags)
{
return FindTokenBase(tokens, begin, end, i => i < tokens.Count, i => i + 1, flags);
}
/// <summary>
/// Given a list of <code>tokens</code>, searches for the next token in <code>tokens</code> that matches the list of <code>flags</code>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="first">the search starting position.</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindNextToken(List<Token> tokens, int first, params TokenFlag[] flags)
{
return FindTokenBase(tokens, first + 1, tokens.Count, i => i < tokens.Count, i => i + 1, flags);
}
/// <summary>
/// Given a list of <code>tokens</code>, searches for the previous token in <code>tokens</code> that matches the list of <code>flags</code>.
/// </summary>
/// <param name="tokens">the list of tokens</param>
/// <param name="begin">the search starting position. Exclusive of position.Pos</param>
/// <param name="flags">the search flags</param>
/// <returns>the search result</returns>
public static int FindPrevToken(List<Token> tokens, int begin, params TokenFlag[] flags)
{
return FindTokenBase(tokens, begin - 1, -1, i => i >= 0, i => i - 1, flags);
}
/// <summary>
/// Given a list of tokens finds the first token that passes <see cref="CheckTokenFlags"/>.
/// </summary>
/// <param name="tokens">the list of the tokens to search</param>
/// <param name="begin">the start index of the search.</param>
/// <param name="end">the end index of the search.</param>
/// <param name="shouldContinue">a function that returns whether or not we should continue searching</param>
/// <param name="next">a function that returns the next search index</param>
/// <param name="flags">the flags that each token should be validated against</param>
/// <returns>the found token</returns>
private static int FindTokenBase(
List<Token> tokens,
int begin,
int end,
Func<int, bool> shouldContinue,
Func<int, int> next,
params TokenFlag[] flags)
{
var find = new List<TokenFlag>();
find.AddRange(flags);
for (int i = begin; shouldContinue(i); i = next(i))
{
var token = tokens[i];
if (CheckTokenFlags(token, find))
{
return i;
}
}
return end;
}
public static bool InListRange(int pos, List<Token> list)
{
return -1 < pos && pos < list.Count;
}
public override bool Equals(object? o)
{
if (this == o)
return true;
if (o is not Token token)
return false;
return Enclosed == token.Enclosed && Category == token.Category && Equals(Content, token.Content);
}
public override int GetHashCode()
{
int hashCode = -1776802967;
hashCode = hashCode * -1521134295 + Category.GetHashCode();
hashCode = hashCode * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Content);
hashCode = hashCode * -1521134295 + Enclosed.GetHashCode();
return hashCode;
}
public override string ToString()
{
return $"Token{{category={Category}, content='{Content}', enclosed={Enclosed}}}";
}
}

View File

@ -0,0 +1,17 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
namespace AnitomySharp;
public struct TokenRange(int offset, int size)
{
public int Offset = offset;
public int Size = size;
}

View File

@ -0,0 +1,322 @@
/*
* Copyright (c) 2014-2017, Eren Okka
* Copyright (c) 2016-2017, Paul Miller
* Copyright (c) 2017-2018, Tyler Bratton
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
using System.Text;
namespace AnitomySharp;
/// <summary>
/// A class that will tokenize an anime filename.
/// </summary>
public class Tokenizer
{
private readonly string _filename;
private readonly List<Element> _elements;
private readonly Options _options;
private readonly List<Token> _tokens;
private static readonly List<Tuple<string, string>> s_brackets =
[
new Tuple<string, string>("(", ")"), // U+0028-U+0029
new Tuple<string, string>("[", "]"), // U+005B-U+005D Square bracket
new Tuple<string, string>("{", "}"), // U+007B-U+007D Curly bracket
new Tuple<string, string>("\u300C", "\u300D"), // Corner bracket
new Tuple<string, string>("\u300E", "\u300E"), // White corner bracket
new Tuple<string, string>("\u3010", "\u3011"), // Black lenticular bracket
new Tuple<string, string>("\uFF08", "\uFF09")
];
/// <summary>
/// Tokenize a filename into <see cref="Element"/>s
/// </summary>
/// <param name="filename">the filename</param>
/// <param name="elements">the list of elements where pre-identified tokens will be added</param>
/// <param name="options">the parser options</param>
/// <param name="tokens">the list of tokens where tokens will be added</param>
public Tokenizer(string filename, List<Element> elements, Options options, List<Token> tokens)
{
_filename = filename;
_elements = elements;
_options = options;
_tokens = tokens;
}
/// <summary>
/// Returns true if tokenization was successful; false otherwise.
/// </summary>
/// <returns></returns>
public bool Tokenize()
{
TokenizeByBrackets();
return _tokens.Count > 0;
}
/// <summary>
/// Adds a token to the internal list of tokens
/// </summary>
/// <param name="category">the token category</param>
/// <param name="enclosed">whether or not the token is enclosed in braces</param>
/// <param name="range">the token range</param>
private void AddToken(Token.TokenCategory category, bool enclosed, TokenRange range)
{
_tokens.Add(new Token(category, StringHelper.SubstringWithCheck(_filename, range.Offset, range.Size), enclosed));
}
private string GetDelimiters(TokenRange range)
{
var delimiters = new StringBuilder();
foreach (var i in Enumerable.Range(range.Offset, Math.Min(_filename.Length, range.Offset + range.Size) - range.Offset)
.Where(value => IsDelimiter(_filename[value])))
{
delimiters.Append(_filename[i]);
}
return delimiters.ToString();
bool IsDelimiter(char c)
{
if (StringHelper.IsAlphanumericChar(c)) return false;
return _options.AllowedDelimiters.Contains(c.ToString()) && !delimiters.ToString().Contains(c.ToString());
}
}
/// <summary>
/// Tokenize by bracket.
/// </summary>
private void TokenizeByBrackets()
{
string matchingBracket = string.Empty;
bool isBracketOpen = false;
for (int i = 0; i < _filename.Length; )
{
int foundIdx = !isBracketOpen ? FindFirstBracket(i, _filename.Length) : _filename.IndexOf(matchingBracket, i, StringComparison.Ordinal);
var range = new TokenRange(i, foundIdx == -1 ? _filename.Length : foundIdx - i);
if (range.Size > 0)
{
// Check if our range contains any known anime identifiers
TokenizeByPreIdentified(isBracketOpen, range);
}
if (foundIdx != -1)
{
// mark as bracket
AddToken(Token.TokenCategory.Bracket, true, new TokenRange(range.Offset + range.Size, 1));
isBracketOpen = !isBracketOpen;
i = foundIdx + 1;
}
else
{
break;
}
}
return;
int FindFirstBracket(int start, int end)
{
for (int i = start; i < end; i++)
{
foreach (var bracket in s_brackets)
{
if (!_filename[i].Equals(char.Parse(bracket.Item1))) continue;
matchingBracket = bracket.Item2;
return i;
}
}
return -1;
}
}
/// <summary>
/// Tokenize by looking for known anime identifiers
/// </summary>
/// <param name="enclosed">whether or not the current <code>range</code> is enclosed in braces</param>
/// <param name="range">the token range</param>
private void TokenizeByPreIdentified(bool enclosed, TokenRange range)
{
var preIdentifiedTokens = new List<TokenRange>();
// Find known anime identifiers
KeywordManager.PeekAndAdd(_filename, range, _elements, preIdentifiedTokens);
int offset = range.Offset;
var subRange = new TokenRange(range.Offset, 0);
while (offset < range.Offset + range.Size)
{
foreach (var preIdentifiedToken in preIdentifiedTokens)
{
if (offset != preIdentifiedToken.Offset) continue;
if (subRange.Size > 0)
{
TokenizeByDelimiters(enclosed, subRange);
}
AddToken(Token.TokenCategory.Identifier, enclosed, preIdentifiedToken);
subRange.Offset = preIdentifiedToken.Offset + preIdentifiedToken.Size;
offset = subRange.Offset - 1; // It's going to be incremented below
}
subRange.Size = ++offset - subRange.Offset;
}
// Either there was no preidentified token range, or we're now about to process the tail of our current range
if (subRange.Size > 0)
{
TokenizeByDelimiters(enclosed, subRange);
}
}
/// <summary>
/// Tokenize by delimiters allowed in <see cref="Options"/>.AllowedDelimiters.
/// </summary>
/// <param name="enclosed">whether or not the current <code>range</code> is enclosed in braces</param>
/// <param name="range">the token range</param>
private void TokenizeByDelimiters(bool enclosed, TokenRange range)
{
string delimiters = GetDelimiters(range);
if (string.IsNullOrEmpty(delimiters))
{
AddToken(Token.TokenCategory.Unknown, enclosed, range);
return;
}
for (int i = range.Offset, end = range.Offset + range.Size; i < end;)
{
int found = Enumerable.Range(i, Math.Min(end, _filename.Length) - i)
.Where(c => delimiters.Contains(_filename[c].ToString()))
.DefaultIfEmpty(end)
.FirstOrDefault();
var subRange = new TokenRange(i, found - i);
if (subRange.Size > 0)
{
AddToken(Token.TokenCategory.Unknown, enclosed, subRange);
}
if (found != end)
{
AddToken(Token.TokenCategory.Delimiter, enclosed, new TokenRange(subRange.Offset + subRange.Size, 1));
i = found + 1;
}
else
{
break;
}
}
ValidateDelimiterTokens();
}
/// <summary>
/// Validates tokens (make sure certain words delimited by certain tokens aren't split)
/// </summary>
private void ValidateDelimiterTokens()
{
for (int i = 0; i < _tokens.Count; i++)
{
var token = _tokens[i];
if (token.Category != Token.TokenCategory.Delimiter) continue;
char delimiter = token.Content[0];
int prevToken = Token.FindPrevToken(_tokens, i, Token.TokenFlag.FlagValid);
int nextToken = Token.FindNextToken(_tokens, i, Token.TokenFlag.FlagValid);
// Check for single-character tokens to prevent splitting group names,
// keywords, episode numbers, etc.
if (delimiter != ' ' && delimiter != '_')
{
// Single character token
if (IsSingleCharacterToken(prevToken))
{
AppendTokenTo(token, _tokens[prevToken]);
while (IsUnknownToken(nextToken))
{
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
nextToken = Token.FindNextToken(_tokens, i, Token.TokenFlag.FlagValid);
if (!IsDelimiterToken(nextToken) || _tokens[nextToken].Content[0] != delimiter) continue;
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
nextToken = Token.FindNextToken(_tokens, nextToken, Token.TokenFlag.FlagValid);
}
continue;
}
if (IsSingleCharacterToken(nextToken))
{
AppendTokenTo(token, _tokens[prevToken]);
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]);
continue;
}
}
// Check for adjacent delimiters
if (IsUnknownToken(prevToken) && IsDelimiterToken(nextToken))
{
char nextDelimiter = _tokens[nextToken].Content[0];
if (delimiter != nextDelimiter && delimiter != ',')
{
if (nextDelimiter is ' ' or '_')
{
AppendTokenTo(token, _tokens[prevToken]);
}
}
}
else if (IsDelimiterToken(prevToken) && IsDelimiterToken(nextToken))
{
char prevDelimiter = _tokens[prevToken].Content[0];
char nextDelimiter = _tokens[nextToken].Content[0];
if (prevDelimiter == nextDelimiter && prevDelimiter != delimiter)
{
token.Category = Token.TokenCategory.Unknown; // e.g. "& in "_&_"
}
}
// Check for other special cases
if (delimiter != '&' && delimiter != '+') continue;
if (!IsUnknownToken(prevToken) || !IsUnknownToken(nextToken)) continue;
if (!StringHelper.IsNumericString(_tokens[prevToken].Content)
|| !StringHelper.IsNumericString(_tokens[nextToken].Content)) continue;
AppendTokenTo(token, _tokens[prevToken]);
AppendTokenTo(_tokens[nextToken], _tokens[prevToken]); // e.g. 01+02
}
// Remove invalid tokens
_tokens.RemoveAll(token => token.Category == Token.TokenCategory.Invalid);
return;
void AppendTokenTo(Token src, Token dest)
{
dest.Content += src.Content;
src.Category = Token.TokenCategory.Invalid;
}
bool IsUnknownToken(int it)
{
return Token.InListRange(it, _tokens) && _tokens[it].Category == Token.TokenCategory.Unknown;
}
bool IsSingleCharacterToken(int it)
{
return IsUnknownToken(it) && _tokens[it].Content.Length == 1 && _tokens[it].Content[0] != '-';
}
bool IsDelimiterToken(int it)
{
return Token.InListRange(it, _tokens) && _tokens[it].Category == Token.TokenCategory.Delimiter;
}
}
}

3
Chiara/Chiara.Tests/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
Data/
*.m3u8
*.ts

View File

@ -0,0 +1,91 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AnitomySharp;
namespace Chiara.Tests.AnitomySharpTests;
public class TestCase
{
[JsonPropertyName("file_name")]
public string FileName { get; init; } = string.Empty;
[JsonPropertyName("ignore")]
public bool Ignore { get; init; }
[JsonPropertyName("results")]
public Dictionary<string, JsonElement> Results { get; init; } = [];
}
public class DataTests(ITestOutputHelper helper)
{
[Fact]
public void ValidateParsingResults()
{
string path = Path.Combine(Environment.CurrentDirectory, "test-cases.json");
List<TestCase>? cases = JsonSerializer.Deserialize<List<TestCase>>(File.ReadAllText(path));
Assert.NotNull(cases);
helper.WriteLine($"Loaded {cases.Count} test cases.");
foreach (var testCase in cases)
{
Verify(testCase);
}
}
private void Verify(TestCase entry)
{
string fileName = entry.FileName;
bool ignore = entry.Ignore;
Dictionary<string, JsonElement> testCases = entry.Results;
if (ignore || string.IsNullOrWhiteSpace(fileName) || testCases.Count == 0)
{
helper.WriteLine($"Ignoring [{fileName}] : {{ results: {testCases.Count} | explicit: {ignore}}}");
return;
}
helper.WriteLine($"Parsing: {fileName}");
Dictionary<string, List<string>> parseResults = ToTestCaseDict(fileName);
foreach (KeyValuePair<string,JsonElement> pair in testCases)
{
Assert.True(parseResults.TryGetValue(pair.Key, out List<string>? value));
Assert.NotNull(value);
if (pair.Value.ValueKind == JsonValueKind.String)
{
Assert.Contains(pair.Value.GetString(), value);
}
if (pair.Value.ValueKind != JsonValueKind.Array)
{
continue;
}
List<string>? exceptedArray = pair.Value.Deserialize<List<string>>();
Assert.NotNull(exceptedArray);
Assert.Equal(exceptedArray, value);
}
}
private static Dictionary<string, List<string>> ToTestCaseDict(string filename)
{
var parseResults = Anitomy.Parse(filename);
var elements = new Dictionary<string, List<string>>();
foreach (var e in parseResults)
{
if (elements.TryGetValue(e.Category.ToString(), out List<string>? value))
{
value.Add(e.Value);
}
else
{
elements.Add(e.Category.ToString(), [e.Value]);
}
}
return elements;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
<PackageReference Include="Moq" Version="4.20.70"/>
<PackageReference Include="xunit" Version="2.4.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Chiara\Chiara.csproj"/>
</ItemGroup>
<Target Name="CopyTestFiles" AfterTargets="Build">
<Copy SourceFiles="AnitomySharpTests/test-cases.json" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true"/>
</Target>
</Project>

View File

@ -0,0 +1,3 @@
global using Xunit;
global using Xunit.Abstractions;
global using Moq;

View File

@ -0,0 +1,27 @@
using Chiara.Abstractions;
using Chiara.Models;
using Chiara.Services;
using Chiara.Tests.Utils;
using Microsoft.Extensions.Logging;
namespace Chiara.Tests.Services;
public class MediaRepositoryScannerTests
{
private readonly DefaultMediaRepositoryScanner _scanner = new(
MockCreator.CreateEmptyMock<IFileStore>(),
MockCreator.CreateEmptyMock<ILogger<DefaultMediaRepositoryScanner>>());
[Fact]
public async Task ScanTest()
{
MediaRepository repository = new() { Path = "/home/ricardo/Documents/Code/CSharp/Chiara/Chiara.Tests/Data" };
List<Album> albums = (await _scanner.ScanAlbumAsync(repository)).ToList();
Assert.Single(albums);
Assert.Contains(albums, a => a.Title == "Genshin Impact - Jade Moon Upon a Sea of Clouds");
Assert.Contains(albums, a => a.Arist == "Yu-Peng Chen, HOYO-MiX");
Assert.Contains(albums, a => a.Songs.Count == 69);
}
}

View File

@ -0,0 +1,20 @@
using Chiara.Services;
using Microsoft.Extensions.Logging;
namespace Chiara.Tests.Utils;
public static class MockCreator
{
public static ILogger<T> CreateNoOutputLogger<T>()
{
Mock<ILogger<T>> mock = new();
return mock.Object;
}
public static T CreateEmptyMock<T>() where T : class
{
Mock<T> mock = new();
return mock.Object;
}
}

47
Chiara/Chiara.sln Normal file
View File

@ -0,0 +1,47 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chiara", "Chiara\Chiara.csproj", "{06FB2F9E-6C0A-4D73-A1B0-D60B116348FB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chiara.Tests", "Chiara.Tests\Chiara.Tests.csproj", "{DD3DCD44-D661-4DE1-B2FD-170D5504361A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utils", "Utils", "{E6D11D0F-E915-41B5-86D4-CCEA8C6CF3A3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnitomySharp", "AnitomySharp\AnitomySharp.csproj", "{4893AB7B-D50D-4C06-9267-E14F28B510E9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".gitea", ".gitea", "{38E1A429-AC02-4C7A-AF97-338D9FF30A0B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{DB9235C8-F148-47B8-A205-BCF978750D73}"
ProjectSection(SolutionItems) = preProject
.gitea\workflows\build.yaml = .gitea\workflows\build.yaml
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{06FB2F9E-6C0A-4D73-A1B0-D60B116348FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{06FB2F9E-6C0A-4D73-A1B0-D60B116348FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{06FB2F9E-6C0A-4D73-A1B0-D60B116348FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{06FB2F9E-6C0A-4D73-A1B0-D60B116348FB}.Release|Any CPU.Build.0 = Release|Any CPU
{DD3DCD44-D661-4DE1-B2FD-170D5504361A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DD3DCD44-D661-4DE1-B2FD-170D5504361A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DD3DCD44-D661-4DE1-B2FD-170D5504361A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DD3DCD44-D661-4DE1-B2FD-170D5504361A}.Release|Any CPU.Build.0 = Release|Any CPU
{4893AB7B-D50D-4C06-9267-E14F28B510E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4893AB7B-D50D-4C06-9267-E14F28B510E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4893AB7B-D50D-4C06-9267-E14F28B510E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4893AB7B-D50D-4C06-9267-E14F28B510E9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{4893AB7B-D50D-4C06-9267-E14F28B510E9} = {E6D11D0F-E915-41B5-86D4-CCEA8C6CF3A3}
{DB9235C8-F148-47B8-A205-BCF978750D73} = {38E1A429-AC02-4C7A-AF97-338D9FF30A0B}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,10 @@
namespace Chiara.Abstractions;
public interface IFile
{
public Guid FileId { get; }
public string ContentType { get; }
public Stream OpenRead();
}

View File

@ -0,0 +1,12 @@
using Chiara.Models;
namespace Chiara.Abstractions;
public interface IFileStore
{
public Task<string> UploadFileAsync(ReadOnlyMemory<byte> buffer, string contentType);
public Task<string> ClarifyLocalFileAsync(string path, string contentType);
public Task<IFile?> GetFileAsync(Guid guid);
}

View File

@ -0,0 +1,10 @@
namespace Chiara.Abstractions;
public interface IMediaItem
{
public string Title { get; }
public string Arist { get; }
public string Path { get; }
}

View File

@ -0,0 +1,12 @@
using Chiara.Models;
namespace Chiara.Abstractions;
public interface IMediaRepositoryScanner
{
public Task<IEnumerable<Album>> ScanAlbumAsync(MediaRepository repository,
CancellationToken cancellationToken = default);
public Task<IEnumerable<ShowSeason>> ScanShowAsync(MediaRepository repository,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
<PackageReference Include="Xabe.FFmpeg" Version="5.2.6" />
</ItemGroup>
<ItemGroup>
<None Include="../.editorconfig" />
<None Include="../.gitignore" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AnitomySharp\AnitomySharp.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,40 @@
using Chiara.DataTransferObjects;
using Chiara.Models;
using Chiara.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Chiara.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AlbumController(ChiaraDbContext dbContext) : ControllerBase
{
[HttpGet]
[ProducesResponseType<IEnumerable<AlbumResponse>>(200)]
public async Task<IActionResult> GetAllAlbums()
{
IQueryable<AlbumResponse> albumResponses = from item in dbContext.Albums.AsNoTracking()
select new AlbumResponse(item);
return Ok(await albumResponses.ToListAsync());
}
[HttpGet("{id}")]
[ProducesResponseType<AlbumResponse>(200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetAlbum([FromRoute] int id)
{
Album? album = await (from item in dbContext.Albums.AsNoTracking()
.Include(a => a.Songs)
where item.Id == id
select item).FirstOrDefaultAsync();
if (album is null)
{
return NotFound();
}
return Ok(new AlbumResponse(album));
}
}

View File

@ -0,0 +1,28 @@
using Chiara.Abstractions;
using Microsoft.AspNetCore.Mvc;
namespace Chiara.Controllers;
[Route("api/[controller]")]
[ApiController]
public class FileController(IFileStore fileStore) : ControllerBase
{
[HttpGet("{key}")]
[ProducesResponseType<FileStreamResult>(200)]
public async Task<IActionResult> DownloadFile([FromRoute] string key)
{
if (!Guid.TryParse(key, out Guid guid))
{
return BadRequest();
}
IFile? file = await fileStore.GetFileAsync(guid);
if (file is null)
{
return NotFound();
}
return File(file.OpenRead(), file.ContentType);
}
}

View File

@ -0,0 +1,152 @@
using System.Net.Mime;
using Chiara.DataTransferObjects;
using Chiara.Models;
using Chiara.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Chiara.Controllers;
[ApiController]
[Route("api/[controller]")]
public class HlsController(
FfmpegService ffmpegService,
ChiaraDbContext dbContext,
ILogger<HlsController> logger,
IOptions<ChiaraOptions> chiaraOptions) : ControllerBase
{
private readonly DirectoryInfo _cacheDirectory = new(chiaraOptions.Value.TemporaryDirectory);
[HttpGet("{file}")]
public IActionResult File([FromRoute] string file)
{
FileInfo fileInfo = new(Path.Combine(_cacheDirectory.FullName, file));
if (!fileInfo.Exists)
{
return NotFound();
}
if (file.EndsWith("m3u8"))
{
return File(fileInfo.OpenRead(), "application/x-mpegURL");
}
if (file.EndsWith("ts"))
{
return File(fileInfo.OpenRead(), "video/MP2T");
}
return File(fileInfo.OpenRead(), MediaTypeNames.Text.Plain);
}
[HttpPost("start/{episodeId}")]
[ProducesResponseType<VideoPlayingResponse>(200)]
[ProducesResponseType(404)]
public async Task<IActionResult> Start([FromRoute] int episodeId)
{
Episode? episode = await (from item in dbContext.Episodes.AsNoTracking()
where item.Id == episodeId
select item).FirstOrDefaultAsync();
if (episode is null)
{
return NotFound();
}
if (ffmpegService.CurrentConversion is not null)
{
logger.LogWarning("Stop running transaction rudely.");
try
{
await ffmpegService.CurrentConversion.RunningTask;
ffmpegService.CurrentConversion = null;
}
catch (Exception e)
{
logger.LogInformation("Stop transaction encounter: {}.", e);
}
}
logger.LogInformation("Start transaction of {}.", episode.Title);
FileInfo videoFile = new(episode.Path);
string name = Guid.NewGuid().ToString();
CancellationTokenSource source = new();
VideoConversion conversion = new(
ffmpegService.StartConversion(videoFile, _cacheDirectory, name, source.Token),
_cacheDirectory,
name,
source);
ffmpegService.CurrentConversion = conversion;
using PeriodicTimer time = new(TimeSpan.FromSeconds(1));
while (!_cacheDirectory.EnumerateFiles().Any(f => f.Name.StartsWith(name)))
{
await time.WaitForNextTickAsync();
}
return Ok(new VideoPlayingResponse { PlayList = $"/api/hls/{name}.m3u8" });
}
[HttpPost("stop")]
[ProducesResponseType(200)]
public async Task<IActionResult> Stop()
{
if (ffmpegService.CurrentConversion is null || ffmpegService.CurrentConversion.RunningTask.IsCanceled)
{
if (ffmpegService.CurrentConversion is not null)
{
logger.LogInformation("Stop finished video transaction.");
try
{
await ffmpegService.CurrentConversion.RunningTask;
}
catch (Exception e)
{
logger.LogInformation("Video encounter: {}.", e);
}
ffmpegService.CurrentConversion = null;
}
return Ok();
}
logger.LogInformation("Stop running video transaction.");
await ffmpegService.CurrentConversion.CancellationTokenSource.CancelAsync();
try
{
await ffmpegService.CurrentConversion.RunningTask;
ffmpegService.CurrentConversion = null;
}
catch (Exception e)
{
logger.LogInformation("Video encounter: {}.", e);
}
ClearCacheDirectory();
return Ok();
}
private void ClearCacheDirectory()
{
IEnumerable<FileInfo> temporaryFiles = from file in _cacheDirectory.EnumerateFiles()
where file.Extension is ".m3u8" or ".ts"
select file;
foreach (FileInfo file in temporaryFiles)
{
if (file.Exists)
{
file.Delete();
}
}
}
}

View File

@ -0,0 +1,74 @@
using Chiara.DataTransferObjects;
using Chiara.Models;
using Chiara.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Chiara.Controllers;
[Route("api/[controller]")]
[ApiController]
public class MediaRepositoryController(RefreshService refreshService, ChiaraDbContext dbContext) : ControllerBase
{
[HttpGet]
[ProducesResponseType<IEnumerable<MediaRepositoryResponse>>(200)]
public async Task<IActionResult> ListRepositories()
{
return Ok(await (from item in dbContext.Repositories.AsNoTracking()
select new MediaRepositoryResponse(item)).ToListAsync());
}
[HttpGet("{id}")]
[ProducesResponseType<MediaRepositoryResponse>(200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetRepository([FromRoute] int id)
{
MediaRepository? repository = await (from item in dbContext.Repositories.AsNoTracking()
.Include(m => m.Albums)
.Include(m => m.Seasons)
where item.Id == id
select item).FirstOrDefaultAsync();
if (repository is null)
{
return NotFound();
}
return Ok(new MediaRepositoryResponse(repository));
}
[HttpPost]
[ProducesResponseType<MediaRepositoryResponse>(201)]
public async Task<IActionResult> CreateRepository([FromBody] CreateMediaRepositoryRequest request)
{
MediaRepository repository = new() { Name = request.Name, Path = request.Path };
await dbContext.Repositories.AddAsync(repository);
await dbContext.SaveChangesAsync();
return Created($"api/MediaRepository/{repository.Id}", new MediaRepositoryResponse(repository));
}
[HttpPost("{id}")]
[ProducesResponseType<MediaRepositoryResponse>(200)]
[ProducesResponseType(404)]
public async Task<IActionResult> RefreshRepository([FromRoute] int id)
{
IQueryable<MediaRepository> mediaRepositoryQuery = from item in dbContext.Repositories
.Include(m => m.Albums)
.Include(m => m.Seasons)
where item.Id == id
select item;
MediaRepository? repository = await mediaRepositoryQuery.FirstOrDefaultAsync();
if (repository is null)
{
return NotFound();
}
await refreshService.Refresh(repository);
return Ok(new MediaRepositoryResponse(await mediaRepositoryQuery.AsNoTracking().FirstAsync()));
}
}

View File

@ -0,0 +1,38 @@
using Chiara.DataTransferObjects;
using Chiara.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Chiara.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SeasonController(ChiaraDbContext dbContext) : ControllerBase
{
[HttpGet]
[ProducesResponseType<IEnumerable<ShowSeasonResponse>>(200)]
public async Task<IActionResult> ListSeasons()
{
return Ok(await (from item in dbContext.Seasons.AsNoTracking()
select new ShowSeasonResponse(item)).ToListAsync());
}
[HttpGet("{id}")]
[ProducesResponseType<ShowSeasonResponse>(200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetSeason([FromRoute] int id)
{
ShowSeasonResponse? response = await (from item in dbContext.Seasons.AsNoTracking()
.Include(s => s.Episodes
.OrderBy(e => e.EpisodeNumber))
where item.Id == id
select new ShowSeasonResponse(item)).FirstOrDefaultAsync();
if (response is null)
{
return NotFound();
}
return Ok(response);
}
}

View File

@ -0,0 +1,39 @@
using Chiara.DataTransferObjects;
using Chiara.Models;
using Chiara.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Chiara.Controllers;
[Route("api/[controller]")]
[ApiController]
public class SongController(ChiaraDbContext dbContext) : ControllerBase
{
[HttpGet]
[ProducesResponseType<IEnumerable<SongResponse>>(200)]
public IActionResult ListSongs()
{
IEnumerable<SongResponse> response = from item in dbContext.Songs.AsNoTracking()
select new SongResponse(item);
return Ok(response);
}
[HttpGet("{id}")]
[ProducesResponseType<SongResponse>(200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetSong([FromRoute] int id)
{
Song? song = await (from item in dbContext.Songs.AsNoTracking()
where item.Id == id
select item).FirstOrDefaultAsync();
if (song is null)
{
return NotFound();
}
return Ok(new SongResponse(song));
}
}

View File

@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using Chiara.Models;
namespace Chiara.DataTransferObjects;
public class AlbumResponse
{
[Required]
public int Id { get; set; }
[Required]
public string Title { get; set; } = string.Empty;
[Required]
public string Arist { get; set; } = string.Empty;
[Required]
public string CoverImageUrl { get; set; } = string.Empty;
[Required]
public List<SongResponse> Songs { get; set; } = [];
public AlbumResponse()
{
}
public AlbumResponse(Album album)
{
Id = album.Id;
Title = album.Title;
Arist = album.Arist;
CoverImageUrl = album.CoverImageUrl;
Songs = album.Songs.Select(s => new SongResponse(s)).ToList();
}
}

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Chiara.DataTransferObjects;
public class CreateMediaRepositoryRequest
{
[Required]
public string Name { get; set; } = string.Empty;
[Required]
public string Path { get; set; } = string.Empty;
}

View File

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using Chiara.Models;
namespace Chiara.DataTransferObjects;
public class EpisodeResponse
{
[Required]
public int Id { get; set; }
[Required]
public string Title { get; set; } = string.Empty;
[Required]
public string EpisodeNumber { get; set; } = string.Empty;
[Required]
public int ShowSeasonId { get; set; }
public EpisodeResponse()
{
}
public EpisodeResponse(Episode episode)
{
Id = episode.Id;
Title = episode.Title;
EpisodeNumber = episode.EpisodeNumber;
ShowSeasonId = episode.ShowSeasonId;
}
}

View File

@ -0,0 +1,30 @@
using Chiara.Models;
namespace Chiara.DataTransferObjects;
public class MediaRepositoryResponse
{
public int Id { get; set; }
public string Path { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public List<AlbumResponse> AlbumResponses { get; set; } = [];
public List<ShowSeasonResponse> ShowSeasonResponses { get; set; } = [];
public MediaRepositoryResponse()
{
}
public MediaRepositoryResponse(MediaRepository repository)
{
Id = repository.Id;
Path = repository.Path;
Name = repository.Name;
AlbumResponses.AddRange(repository.Albums.Select(a => new AlbumResponse(a)));
ShowSeasonResponses.AddRange(repository.Seasons.Select(s => new ShowSeasonResponse(s)));
}
}

View File

@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using Chiara.Models;
namespace Chiara.DataTransferObjects;
public class ShowSeasonResponse
{
[Required]
public int Id { get; set; }
[Required]
public string Title { get; set; } = string.Empty;
[Required]
public List<EpisodeResponse> Episodes { get; set; } = [];
public ShowSeasonResponse()
{
}
public ShowSeasonResponse(ShowSeason showSeason)
{
Id = showSeason.Id;
Title = showSeason.Title;
Episodes.AddRange(showSeason.Episodes.Select(e => new EpisodeResponse(e)));
}
}

View File

@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using Chiara.Models;
namespace Chiara.DataTransferObjects;
public class SongResponse
{
[Required]
public int Id { get; set; }
[Required]
public string Title { get; set; } = string.Empty;
[Required]
public string Arist { get; set; } = string.Empty;
[Required]
public string CoverImageUrl { get; set; } = string.Empty;
[Required]
public string Url { get; set; } = string.Empty;
public SongResponse()
{
}
public SongResponse(Song song)
{
Id = song.Id;
Title = song.Title;
Arist = song.Arist;
CoverImageUrl = song.CoverImageUrl;
Url = song.Url;
}
}

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Chiara.DataTransferObjects;
public class VideoPlayingResponse
{
[Required]
public string PlayList { get; set; } = string.Empty;
}

8
Chiara/Chiara/Dockerfile Normal file
View File

@ -0,0 +1,8 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0
RUN sed -i "s?http://deb.debian.org?https://mirrors.cernet.edu.cn?g" /etc/apt/sources.list.d/debian.sources
RUN apt update && apt upgrade -y && apt install -y ffmpeg
WORKDIR /App
COPY bin/Release/net8.0/publish /App
ENTRYPOINT ["dotnet", "/App/Chiara.dll"]

View File

@ -0,0 +1,19 @@
using Chiara.Abstractions;
using Chiara.Services;
namespace Chiara.Extensions;
public static class ServiceCollectionExtensions
{
public static void AddChiaraService(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton<LocalFileService>();
serviceCollection.AddSingleton<FileService>();
serviceCollection.AddSingleton<IFileStore, FileService>(provider => provider.GetRequiredService<FileService>());
serviceCollection.AddTransient<IMediaRepositoryScanner, DefaultMediaRepositoryScanner>();
serviceCollection.AddTransient<RefreshService>();
serviceCollection.AddHostedService<MigrationService>();
serviceCollection.AddHostedService<FileService>(provider => provider.GetRequiredService<FileService>());
serviceCollection.AddSingleton<FfmpegService>();
}
}

View File

@ -0,0 +1,12 @@
using Chiara.Models;
namespace Chiara.Extensions;
public static class WebApplicationBuilderExtensions
{
public static WebApplicationBuilder AddChiaraOptions(this WebApplicationBuilder builder)
{
builder.Services.Configure<ChiaraOptions>(builder.Configuration.GetSection(ChiaraOptions.OptionName));
return builder;
}
}

View File

@ -0,0 +1,58 @@
// <auto-generated />
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240521123215_CreateDatabase")]
partial class CreateDatabase
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Author")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Songs");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class CreateDatabase : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Songs",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Title = table.Column<string>(type: "text", nullable: false),
Author = table.Column<string>(type: "text", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
CoverImageUrl = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Songs", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Songs");
}
}
}

View File

@ -0,0 +1,131 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240529140720_AddAlbum")]
partial class AddAlbum
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ForeignId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ForeignId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class AddAlbum : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Url",
table: "Songs",
newName: "Path");
migrationBuilder.RenameColumn(
name: "Author",
table: "Songs",
newName: "Arist");
migrationBuilder.AddColumn<int>(
name: "AlbumId",
table: "Songs",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Albums",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Title = table.Column<string>(type: "text", nullable: false),
Arist = table.Column<string>(type: "text", nullable: false),
Path = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Albums", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Files",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ForeignId = table.Column<Guid>(type: "uuid", nullable: false),
Content = table.Column<byte[]>(type: "bytea", nullable: false),
ContentType = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Files", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Songs_AlbumId",
table: "Songs",
column: "AlbumId");
migrationBuilder.CreateIndex(
name: "IX_Files_ForeignId",
table: "Files",
column: "ForeignId");
migrationBuilder.AddForeignKey(
name: "FK_Songs_Albums_AlbumId",
table: "Songs",
column: "AlbumId",
principalTable: "Albums",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Songs_Albums_AlbumId",
table: "Songs");
migrationBuilder.DropTable(
name: "Albums");
migrationBuilder.DropTable(
name: "Files");
migrationBuilder.DropIndex(
name: "IX_Songs_AlbumId",
table: "Songs");
migrationBuilder.DropColumn(
name: "AlbumId",
table: "Songs");
migrationBuilder.RenameColumn(
name: "Path",
table: "Songs",
newName: "Url");
migrationBuilder.RenameColumn(
name: "Arist",
table: "Songs",
newName: "Author");
}
}
}

View File

@ -0,0 +1,135 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240530030313_AddSongUrl")]
partial class AddSongUrl
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ForeignId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ForeignId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class AddSongUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Url",
table: "Songs",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Url",
table: "Songs");
}
}
}

View File

@ -0,0 +1,135 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240601063131_UnifyFile")]
partial class UnifyFile
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FileId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class UnifyFile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "ForeignId",
table: "Files",
newName: "FileId");
migrationBuilder.RenameIndex(
name: "IX_Files_ForeignId",
table: "Files",
newName: "IX_Files_FileId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "FileId",
table: "Files",
newName: "ForeignId");
migrationBuilder.RenameIndex(
name: "IX_Files_FileId",
table: "Files",
newName: "IX_Files_ForeignId");
}
}
}

View File

@ -0,0 +1,139 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240601065653_AlbumCoverImageUrl")]
partial class AlbumCoverImageUrl
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FileId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class AlbumCoverImageUrl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CoverImageUrl",
table: "Albums",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CoverImageUrl",
table: "Albums");
}
}
}

View File

@ -0,0 +1,144 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240607080833_AddHashValue")]
partial class AddHashValue
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.Property<byte[]>("HashValue")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("FileId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class AddHashValue : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "Files",
type: "character varying(20)",
maxLength: 20,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AddColumn<byte[]>(
name: "HashValue",
table: "Files",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HashValue",
table: "Files");
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "Files",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(20)",
oldMaxLength: 20);
}
}
}

View File

@ -0,0 +1,275 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240608144941_ShowSeasonAndEpisodeAndMediaRepository")]
partial class ShowSeasonAndEpisodeAndMediaRepository
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ParentRepositoryId")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentRepositoryId");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.Property<byte[]>("HashValue")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("FileId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Episode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ShowSeasonId")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ShowSeasonId");
b.ToTable("Episodes");
});
modelBuilder.Entity("Chiara.Models.MediaRepository", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Repositories");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ParentRepositoryId")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentRepositoryId");
b.ToTable("Seasons");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.HasOne("Chiara.Models.MediaRepository", "ParentRepository")
.WithMany("Albums")
.HasForeignKey("ParentRepositoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentRepository");
});
modelBuilder.Entity("Chiara.Models.Episode", b =>
{
b.HasOne("Chiara.Models.ShowSeason", "Season")
.WithMany("Episodes")
.HasForeignKey("ShowSeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.HasOne("Chiara.Models.MediaRepository", "ParentRepository")
.WithMany("Seasons")
.HasForeignKey("ParentRepositoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentRepository");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
modelBuilder.Entity("Chiara.Models.MediaRepository", b =>
{
b.Navigation("Albums");
b.Navigation("Seasons");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class ShowSeasonAndEpisodeAndMediaRepository : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ParentRepositoryId",
table: "Albums",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "Repositories",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: false),
Path = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Repositories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Seasons",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Title = table.Column<string>(type: "text", nullable: false),
Arist = table.Column<string>(type: "text", nullable: false),
Path = table.Column<string>(type: "text", nullable: false),
ParentRepositoryId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Seasons", x => x.Id);
table.ForeignKey(
name: "FK_Seasons_Repositories_ParentRepositoryId",
column: x => x.ParentRepositoryId,
principalTable: "Repositories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Episodes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Title = table.Column<string>(type: "text", nullable: false),
Arist = table.Column<string>(type: "text", nullable: false),
Path = table.Column<string>(type: "text", nullable: false),
ShowSeasonId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Episodes", x => x.Id);
table.ForeignKey(
name: "FK_Episodes_Seasons_ShowSeasonId",
column: x => x.ShowSeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Albums_ParentRepositoryId",
table: "Albums",
column: "ParentRepositoryId");
migrationBuilder.CreateIndex(
name: "IX_Episodes_ShowSeasonId",
table: "Episodes",
column: "ShowSeasonId");
migrationBuilder.CreateIndex(
name: "IX_Seasons_ParentRepositoryId",
table: "Seasons",
column: "ParentRepositoryId");
migrationBuilder.AddForeignKey(
name: "FK_Albums_Repositories_ParentRepositoryId",
table: "Albums",
column: "ParentRepositoryId",
principalTable: "Repositories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Albums_Repositories_ParentRepositoryId",
table: "Albums");
migrationBuilder.DropTable(
name: "Episodes");
migrationBuilder.DropTable(
name: "Seasons");
migrationBuilder.DropTable(
name: "Repositories");
migrationBuilder.DropIndex(
name: "IX_Albums_ParentRepositoryId",
table: "Albums");
migrationBuilder.DropColumn(
name: "ParentRepositoryId",
table: "Albums");
}
}
}

View File

@ -0,0 +1,278 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240609065607_AddEpisodeNumber")]
partial class AddEpisodeNumber
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ParentRepositoryId")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentRepositoryId");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.Property<byte[]>("HashValue")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("FileId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Episode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<int>("EpisodeNumber")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ShowSeasonId")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ShowSeasonId");
b.ToTable("Episodes");
});
modelBuilder.Entity("Chiara.Models.MediaRepository", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Repositories");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ParentRepositoryId")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentRepositoryId");
b.ToTable("Seasons");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.HasOne("Chiara.Models.MediaRepository", "ParentRepository")
.WithMany("Albums")
.HasForeignKey("ParentRepositoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentRepository");
});
modelBuilder.Entity("Chiara.Models.Episode", b =>
{
b.HasOne("Chiara.Models.ShowSeason", "Season")
.WithMany("Episodes")
.HasForeignKey("ShowSeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.HasOne("Chiara.Models.MediaRepository", "ParentRepository")
.WithMany("Seasons")
.HasForeignKey("ParentRepositoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentRepository");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
modelBuilder.Entity("Chiara.Models.MediaRepository", b =>
{
b.Navigation("Albums");
b.Navigation("Seasons");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class AddEpisodeNumber : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeNumber",
table: "Episodes",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EpisodeNumber",
table: "Episodes");
}
}
}

View File

@ -0,0 +1,279 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
[Migration("20240611062733_UpdateEpisodeType")]
partial class UpdateEpisodeType
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ParentRepositoryId")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentRepositoryId");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.Property<byte[]>("HashValue")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("FileId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Episode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EpisodeNumber")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ShowSeasonId")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ShowSeasonId");
b.ToTable("Episodes");
});
modelBuilder.Entity("Chiara.Models.MediaRepository", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Repositories");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ParentRepositoryId")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentRepositoryId");
b.ToTable("Seasons");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.HasOne("Chiara.Models.MediaRepository", "ParentRepository")
.WithMany("Albums")
.HasForeignKey("ParentRepositoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentRepository");
});
modelBuilder.Entity("Chiara.Models.Episode", b =>
{
b.HasOne("Chiara.Models.ShowSeason", "Season")
.WithMany("Episodes")
.HasForeignKey("ShowSeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.HasOne("Chiara.Models.MediaRepository", "ParentRepository")
.WithMany("Seasons")
.HasForeignKey("ParentRepositoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentRepository");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
modelBuilder.Entity("Chiara.Models.MediaRepository", b =>
{
b.Navigation("Albums");
b.Navigation("Seasons");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Chiara.Migrations
{
/// <inheritdoc />
public partial class UpdateEpisodeType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "EpisodeNumber",
table: "Episodes",
type: "text",
nullable: false,
oldClrType: typeof(int),
oldType: "integer");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "EpisodeNumber",
table: "Episodes",
type: "integer",
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
}
}

View File

@ -0,0 +1,276 @@
// <auto-generated />
using System;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Chiara.Migrations
{
[DbContext(typeof(ChiaraDbContext))]
partial class ChiaraDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ParentRepositoryId")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentRepositoryId");
b.ToTable("Albums");
});
modelBuilder.Entity("Chiara.Models.DatabaseFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.Property<byte[]>("HashValue")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.HasIndex("FileId");
b.ToTable("Files");
});
modelBuilder.Entity("Chiara.Models.Episode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EpisodeNumber")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ShowSeasonId")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ShowSeasonId");
b.ToTable("Episodes");
});
modelBuilder.Entity("Chiara.Models.MediaRepository", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Repositories");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ParentRepositoryId")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentRepositoryId");
b.ToTable("Seasons");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AlbumId")
.HasColumnType("integer");
b.Property<string>("Arist")
.IsRequired()
.HasColumnType("text");
b.Property<string>("CoverImageUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AlbumId");
b.ToTable("Songs");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.HasOne("Chiara.Models.MediaRepository", "ParentRepository")
.WithMany("Albums")
.HasForeignKey("ParentRepositoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentRepository");
});
modelBuilder.Entity("Chiara.Models.Episode", b =>
{
b.HasOne("Chiara.Models.ShowSeason", "Season")
.WithMany("Episodes")
.HasForeignKey("ShowSeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.HasOne("Chiara.Models.MediaRepository", "ParentRepository")
.WithMany("Seasons")
.HasForeignKey("ParentRepositoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentRepository");
});
modelBuilder.Entity("Chiara.Models.Song", b =>
{
b.HasOne("Chiara.Models.Album", "Album")
.WithMany("Songs")
.HasForeignKey("AlbumId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Album");
});
modelBuilder.Entity("Chiara.Models.Album", b =>
{
b.Navigation("Songs");
});
modelBuilder.Entity("Chiara.Models.MediaRepository", b =>
{
b.Navigation("Albums");
b.Navigation("Seasons");
});
modelBuilder.Entity("Chiara.Models.ShowSeason", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,42 @@
using Chiara.Abstractions;
namespace Chiara.Models;
public class Album : IMediaItem, IEquatable<Album>
{
public int Id { get; set; }
public string Title { get; init; } = string.Empty;
public string Arist { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public string CoverImageUrl { get; set; } = string.Empty;
public List<Song> Songs { get; set; } = [];
public int ParentRepositoryId { get; set; }
public required MediaRepository ParentRepository { get; set; }
public bool Equals(Album? other)
{
if (other is null)
{
return false;
}
return Title == other.Title && Arist == other.Arist;
}
public override bool Equals(object? obj)
{
return obj is Album other && Equals(other);
}
public override int GetHashCode()
{
return Title.GetHashCode() ^ Arist.GetHashCode();
}
}

View File

@ -0,0 +1,8 @@
namespace Chiara.Models;
public class ChiaraOptions
{
public const string OptionName = "Chiara";
public string TemporaryDirectory { get; set; } = string.Empty;
}

View File

@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using Chiara.Abstractions;
namespace Chiara.Models;
public class DatabaseFile : IFile
{
public int Id { get; set; }
public Guid FileId { get; set; }
public byte[] Content { get; set; } = [];
public byte[] HashValue { get; set; } = [];
[MaxLength(20)]
public string ContentType { get; set; } = string.Empty;
public Stream OpenRead()
{
MemoryStream stream = new(Content);
return stream;
}
}

View File

@ -0,0 +1,37 @@
using Chiara.Abstractions;
namespace Chiara.Models;
public class Episode : IMediaItem, IEquatable<Episode>
{
public int Id { get; set; }
public string Title { get; init; } = string.Empty;
public string Arist { get; init; } = string.Empty;
public string EpisodeNumber { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public int ShowSeasonId { get; set; }
public required ShowSeason Season { get; set; }
public bool Equals(Episode? other)
{
if (other is null)
{
return false;
}
return Title == other.Title && Arist == other.Arist && EpisodeNumber == other.EpisodeNumber;
}
public override bool Equals(object? obj)
{
return obj is Episode other && Equals(other);
}
public override int GetHashCode() => Title.GetHashCode() ^ Arist.GetHashCode() ^ EpisodeNumber.GetHashCode();
}

View File

@ -0,0 +1,17 @@
using Chiara.Abstractions;
namespace Chiara.Models;
public class LocalFile(string path, string contentType) : IFile
{
public Guid FileId { get; } = Guid.NewGuid();
public FileInfo File { get; } = new(path);
public string ContentType { get; } = contentType;
public Stream OpenRead()
{
return File.OpenRead();
}
}

View File

@ -0,0 +1,16 @@
namespace Chiara.Models;
public static class MediaItemTypes
{
public static readonly HashSet<string> MusicTypes =
[
".mp3",
".flac"
];
public static readonly HashSet<string> VideoTypes =
[
".mp4",
".mkv"
];
}

View File

@ -0,0 +1,14 @@
namespace Chiara.Models;
public class MediaRepository
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public List<Album> Albums { get; set; } = [];
public List<ShowSeason> Seasons { get; set; } = [];
}

View File

@ -0,0 +1,34 @@
using Chiara.Abstractions;
namespace Chiara.Models;
public class ShowSeason : IMediaItem, IEquatable<ShowSeason>
{
public int Id { get; set; }
public string Title { get; init; } = string.Empty;
public string Arist { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public List<Episode> Episodes { get; set; } = [];
public int ParentRepositoryId { get; set; }
public required MediaRepository ParentRepository { get; set; }
public bool Equals(ShowSeason? other)
{
if (other is null)
{
return false;
}
return Title == other.Title && Arist == other.Arist;
}
public override bool Equals(object? obj) => obj is ShowSeason other && Equals(other);
public override int GetHashCode() => Title.GetHashCode() ^ Title.GetHashCode();
}

View File

@ -0,0 +1,22 @@
using Chiara.Abstractions;
namespace Chiara.Models;
public class Song : IMediaItem
{
public int Id { get; set; }
public string Title { get; init; } = string.Empty;
public string Arist { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public string CoverImageUrl { get; init; } = string.Empty;
public string Url { get; init; } = string.Empty;
public int AlbumId { get; set; }
public required Album Album { get; set; }
}

35
Chiara/Chiara/Program.cs Normal file
View File

@ -0,0 +1,35 @@
using Chiara.Extensions;
using Chiara.Services;
using Microsoft.EntityFrameworkCore;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.AddChiaraOptions();
builder.Services.AddSwaggerGen();
builder.Services.AddControllers();
builder.Services.AddDbContext<ChiaraDbContext>(options =>
{
string? connectionString = builder.Configuration.GetConnectionString("Postgres");
if (connectionString is null)
{
throw new InvalidOperationException("Failed to get postgres connection string.");
}
options.UseNpgsql(connectionString);
});
builder.Services.AddChiaraService();
WebApplication application = builder.Build();
if (application.Environment.IsDevelopment())
{
application.UseSwagger();
application.UseSwaggerUI();
}
application.UseStaticFiles();
application.MapControllers();
await application.RunAsync();

View File

@ -0,0 +1,23 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:6716",
"sslPort": 44314
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "http://0.0.0.0:5078",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,49 @@
using Chiara.Models;
using Microsoft.EntityFrameworkCore;
namespace Chiara.Services;
public class ChiaraDbContext(DbContextOptions<ChiaraDbContext> options) : DbContext(options)
{
public DbSet<Song> Songs { get; init; }
public DbSet<Album> Albums { get; init; }
public DbSet<DatabaseFile> Files { get; init; }
public DbSet<ShowSeason> Seasons { get; init; }
public DbSet<Episode> Episodes { get; init; }
public DbSet<MediaRepository> Repositories { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Album>()
.HasMany(e => e.Songs)
.WithOne(e => e.Album)
.HasForeignKey(e => e.AlbumId)
.IsRequired();
modelBuilder.Entity<ShowSeason>()
.HasMany(s => s.Episodes)
.WithOne(e => e.Season)
.HasForeignKey(e => e.ShowSeasonId)
.IsRequired();
modelBuilder.Entity<MediaRepository>()
.HasMany(m => m.Albums)
.WithOne(a => a.ParentRepository)
.HasForeignKey(a => a.ParentRepositoryId)
.IsRequired();
modelBuilder.Entity<MediaRepository>()
.HasMany(m => m.Seasons)
.WithOne(s => s.ParentRepository)
.HasForeignKey(s => s.ParentRepositoryId)
.IsRequired();
modelBuilder.Entity<DatabaseFile>()
.HasIndex(f => f.FileId);
}
}

View File

@ -0,0 +1,229 @@
using System.Collections.Concurrent;
using AnitomySharp;
using Chiara.Abstractions;
using Chiara.Models;
using TagLib;
namespace Chiara.Services;
public class DefaultMediaRepositoryScanner(
IFileStore fileStore,
ILogger<DefaultMediaRepositoryScanner> logger) : IMediaRepositoryScanner
{
public async Task<IEnumerable<Album>> ScanAlbumAsync(MediaRepository mediaRepository,
CancellationToken cancellationToken = default)
{
DirectoryInfo rootDirectory = new(mediaRepository.Path);
if (!rootDirectory.Exists)
{
throw new InvalidOperationException();
}
Dictionary<Album, List<Song>> albums = [];
foreach (Song song in await ScanSongAsync(rootDirectory, cancellationToken))
{
if (albums.TryGetValue(song.Album, out List<Song>? songs))
{
songs.Add(song);
}
else
{
albums.Add(song.Album, [song]);
}
}
return albums.Select(pair =>
{
pair.Value.ForEach(s => s.Album = pair.Key);
pair.Key.Songs = pair.Value;
pair.Key.CoverImageUrl =
Random.Shared.GetItems(pair.Key.Songs.Select(s => s.CoverImageUrl).ToArray(), 1)[0];
return pair.Key;
});
}
public async Task<IEnumerable<ShowSeason>> ScanShowAsync(MediaRepository repository,
CancellationToken cancellationToken = default)
{
DirectoryInfo rootDirectory = new(repository.Path);
if (!rootDirectory.Exists)
{
throw new InvalidOperationException();
}
Dictionary<ShowSeason, List<Episode>> shows = [];
foreach (Episode episode in await ScanVideoAsync(rootDirectory, cancellationToken))
{
if (shows.TryGetValue(episode.Season, out List<Episode>? episodes))
{
episodes.Add(episode);
}
else
{
shows.Add(episode.Season, [episode]);
}
}
return shows.Select(pair =>
{
pair.Value.ForEach(e =>
{
e.Season = pair.Key;
});
pair.Key.Episodes = pair.Value;
return pair.Key;
});
}
private async Task<IEnumerable<Song>> ScanSongAsync(DirectoryInfo directory, CancellationToken cancellationToken)
{
ConcurrentBag<Song> songs = [];
await Parallel.ForEachAsync(directory.EnumerateDirectories(), cancellationToken, async (info, token) =>
{
foreach (Song song in await ScanSongAsync(info, token))
{
songs.Add(song);
}
});
await Parallel.ForEachAsync(directory.EnumerateFiles(), cancellationToken, async (f, _) =>
{
if (!MediaItemTypes.MusicTypes.Contains(f.Extension))
{
return;
}
try
{
TagLib.File tagFile = TagLib.File.Create(f.FullName);
IPicture? picture = tagFile.Tag.Pictures.FirstOrDefault();
string? coverImageUrl = null;
if (picture is not null)
{
coverImageUrl = "/api/file/" + await fileStore.UploadFileAsync(picture.Data.Data, picture.MimeType);
}
Song song = new()
{
Title = tagFile.Tag.Title ?? f.Name,
Arist = tagFile.Tag.FirstPerformer ?? "Default Arist",
Path = f.FullName,
Url = $"/api/file/{await fileStore.ClarifyLocalFileAsync(f.FullName, tagFile.MimeType)}",
CoverImageUrl = coverImageUrl ?? string.Empty,
Album = new Album
{
// 避免空应用错误
Title = tagFile.Tag.Album ?? "Default Album",
Arist = tagFile.Tag.FirstAlbumArtist ?? "Default Artist",
Path = f.Directory is null ? string.Empty : f.Directory.FullName,
ParentRepository = new MediaRepository()
}
};
songs.Add(song);
}
catch (UnsupportedFormatException e)
{
logger.LogInformation("Failed to parser file {}: {}.", f.Name, e);
}
});
return songs;
}
private async Task<IEnumerable<Episode>> ScanVideoAsync(DirectoryInfo directory,
CancellationToken cancellationToken)
{
ConcurrentBag<Episode> episodes = [];
await Parallel.ForEachAsync(directory.EnumerateDirectories(), cancellationToken, async (d, token) =>
{
foreach (Episode episode in await ScanVideoAsync(d, token))
{
episodes.Add(episode);
}
});
await Parallel.ForEachAsync(directory.EnumerateFiles(), cancellationToken, async (f, _) =>
{
if (!MediaItemTypes.VideoTypes.Contains(f.Extension))
{
return;
}
List<Element> elements = Anitomy.Parse(f.Name).ToList();
Element? titleElement = (from item in elements
where item.Category == ElementCategory.ElementAnimeTitle
select item).FirstOrDefault();
if (titleElement is null)
{
return;
}
Element? episodeNumberElement = (from item in elements
where item.Category == ElementCategory.ElementEpisodeNumber
select item).FirstOrDefault();
if (episodeNumberElement is null)
{
return;
}
Element? episodeTitleElement = (from item in elements
where item.Category == ElementCategory.ElementEpisodeTitle
select item).FirstOrDefault();
if (episodeTitleElement is null)
{
Episode episode = new()
{
Title = $"{titleElement.Value} E{episodeNumberElement.Value}",
Arist = string.Empty,
Path = f.FullName,
EpisodeNumber = episodeNumberElement.Value,
Season = new ShowSeason
{
Title = titleElement.Value,
Arist = string.Empty,
Path = f.Directory?.FullName ?? string.Empty,
ParentRepository = new MediaRepository()
}
};
episodes.Add(episode);
}
else
{
Episode episode = new()
{
Title = episodeTitleElement.Value,
Arist = string.Empty,
Path = f.FullName,
EpisodeNumber = episodeNumberElement.Value,
Season = new ShowSeason
{
Title = titleElement.Value,
Arist = string.Empty,
Path = f.Directory?.FullName ?? string.Empty,
ParentRepository = new MediaRepository()
}
};
episodes.Add(episode);
}
});
return episodes;
}
}

View File

@ -0,0 +1,30 @@
using Xabe.FFmpeg;
namespace Chiara.Services;
public record VideoConversion(Task<IConversionResult> RunningTask,
DirectoryInfo CacheDirectory,
string Name,
CancellationTokenSource CancellationTokenSource);
public class FfmpegService
{
public VideoConversion? CurrentConversion { get; set; }
public async Task<IConversionResult> StartConversion(FileInfo video, DirectoryInfo cacheDirectory, string name,
CancellationToken cancellationToken)
{
IMediaInfo inputVideo = await FFmpeg.GetMediaInfo(video.FullName, cancellationToken);
IConversion conversion = FFmpeg.Conversions.New()
.AddStream(inputVideo.Streams)
.AddParameter("-re", ParameterPosition.PreInput)
.AddParameter("-c:v h264 -f hls")
.AddParameter("-profile:v high10")
.AddParameter("-hls_list_size 10 -hls_time 10 -hls_base_url /api/hls/")
.AddParameter("-hls_flags delete_segments")
.SetOutput(Path.Combine(cacheDirectory.FullName, $"{name}.m3u8"));
return await conversion.Start(cancellationToken);
}
}

View File

@ -0,0 +1,88 @@
using System.Security.Cryptography;
using System.Threading.Channels;
using Chiara.Abstractions;
using Chiara.Models;
using Microsoft.EntityFrameworkCore;
namespace Chiara.Services;
public sealed class FileService(
IServiceProvider serviceProvider,
LocalFileService localFileService,
ILogger<FileService> logger)
: BackgroundService, IFileStore
{
private readonly Channel<DatabaseFile> _channel = Channel.CreateUnbounded<DatabaseFile>(new UnboundedChannelOptions
{
SingleReader = true
});
public async Task<string> UploadFileAsync(ReadOnlyMemory<byte> buffer, string contentType)
{
using IServiceScope scope = serviceProvider.CreateScope();
await using ChiaraDbContext dbContent = scope.ServiceProvider.GetRequiredService<ChiaraDbContext>();
await using Stream stream = new MemoryStream();
await stream.WriteAsync(buffer);
stream.Position = 0;
using HMACSHA256 hmacsha256 = new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
byte[] hash = await hmacsha256.ComputeHashAsync(stream);
DatabaseFile? existedFile = await (from item in dbContent.Files.AsNoTracking()
where item.HashValue == hash && item.ContentType == contentType
select item).FirstOrDefaultAsync();
if (existedFile is not null)
{
return existedFile.FileId.ToString();
}
DatabaseFile file = new()
{
FileId = Guid.NewGuid(), Content = buffer.ToArray(), ContentType = contentType, HashValue = hash
};
await _channel.Writer.WriteAsync(file);
return file.FileId.ToString();
}
public Task<string> ClarifyLocalFileAsync(string path, string contentType)
=> localFileService.ClarifyFile(path, contentType);
public async Task<IFile?> GetFileAsync(Guid guid)
{
using IServiceScope scope = serviceProvider.CreateScope();
await using ChiaraDbContext dbContext = scope.ServiceProvider.GetRequiredService<ChiaraDbContext>();
DatabaseFile? file = await (from item in dbContext.Files.AsNoTracking()
where item.FileId == guid
select item).FirstOrDefaultAsync();
if (file is not null)
{
return file;
}
return localFileService.ReadFile(guid);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using IServiceScope scope = serviceProvider.CreateScope();
await using ChiaraDbContext dbContext = scope.ServiceProvider.GetRequiredService<ChiaraDbContext>();
while (await _channel.Reader.WaitToReadAsync(stoppingToken))
{
if (!_channel.Reader.TryRead(out DatabaseFile? file))
{
continue;
}
logger.LogInformation("Receive file from channel: {}.", file.FileId);
await dbContext.Files.AddAsync(file, stoppingToken);
await dbContext.SaveChangesAsync(stoppingToken);
}
}
}

View File

@ -0,0 +1,36 @@
using System.Collections.Concurrent;
using Chiara.Models;
namespace Chiara.Services;
public class LocalFileService
{
private readonly ConcurrentDictionary<Guid, LocalFile> _idMap = [];
private readonly ConcurrentDictionary<string, LocalFile> _pathMap = [];
public Task<string> ClarifyFile(string path, string contentType)
{
LocalFile localFile = new(path, contentType);
if (!localFile.File.Exists)
{
throw new InvalidOperationException();
}
if (_pathMap.TryGetValue(localFile.File.FullName, out LocalFile? existedFile))
{
return Task.FromResult(existedFile.FileId.ToString());
}
_idMap.TryAdd(localFile.FileId, localFile);
_pathMap.TryAdd(localFile.File.FullName, localFile);
return Task.FromResult(localFile.FileId.ToString());
}
public LocalFile? ReadFile(Guid guid)
{
return _idMap.GetValueOrDefault(guid);
}
}

View File

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
namespace Chiara.Services;
public class MigrationService(IServiceProvider serviceProvider) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using IServiceScope scope = serviceProvider.CreateScope();
ChiaraDbContext dbContext = scope.ServiceProvider.GetRequiredService<ChiaraDbContext>();
await EnsureDatabaseAsync(dbContext, stoppingToken);
await RunMigrationAsync(dbContext, stoppingToken);
}
private static async Task EnsureDatabaseAsync(ChiaraDbContext dbContext, CancellationToken cancellationToken)
{
IRelationalDatabaseCreator creator = dbContext.GetService<IRelationalDatabaseCreator>();
IExecutionStrategy strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
if (!await creator.ExistsAsync(cancellationToken))
{
await creator.CreateAsync(cancellationToken);
}
});
}
private static async Task RunMigrationAsync(ChiaraDbContext dbContext, CancellationToken cancellationToken)
{
IExecutionStrategy strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using IDbContextTransaction transaction =
await dbContext.Database.BeginTransactionAsync(cancellationToken);
await dbContext.Database.MigrateAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
});
}
}

View File

@ -0,0 +1,45 @@
using Chiara.Abstractions;
using Chiara.Models;
namespace Chiara.Services;
public class RefreshService(
ChiaraDbContext dbContext,
IMediaRepositoryScanner scanner)
{
public async Task Refresh(MediaRepository repository)
{
dbContext.Albums.RemoveRange(repository.Albums);
dbContext.Seasons.RemoveRange(repository.Seasons);
await dbContext.SaveChangesAsync();
IEnumerable<Album> albums = await scanner.ScanAlbumAsync(repository);
foreach (Album album in albums)
{
List<Song> songs = album.Songs;
album.Songs = [];
repository.Albums.Add(album);
await dbContext.SaveChangesAsync();
songs.ForEach(s => s.AlbumId = album.Id);
await dbContext.Songs.AddRangeAsync(songs);
await dbContext.SaveChangesAsync();
}
IEnumerable<ShowSeason> seasons = await scanner.ScanShowAsync(repository);
foreach (ShowSeason season in seasons)
{
List<Episode> episodes = season.Episodes;
season.Episodes = [];
repository.Seasons.Add(season);
await dbContext.SaveChangesAsync();
episodes.ForEach(s => s.ShowSeasonId = season.Id);
await dbContext.Episodes.AddRangeAsync(episodes);
await dbContext.SaveChangesAsync();
}
}
}

View File

@ -0,0 +1,14 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"Postgres": "Host=localhost;Database=chiara;Username=postgres;Password=12345678"
},
"Repository": {
"Path": "/home/ricardo/Documents/Code/CSharp/Chiara/Chiara.Tests/Data"
}
}

View File

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

View File

@ -0,0 +1,28 @@
version: "3.9"
services:
db:
image: postgres:15
container_name: chiara-db
environment:
POSTGRES_PASSWORD: 12345678
chiara:
image: chiara:latest
container_name: chiara-app
depends_on:
- db
environment:
TZ: "Asia/Shanghai"
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Postgres: "Host=db;Database=chiara;Username=postgres;Password=12345678"
Repository__Path: "/data"
Chiara__TemporaryDirectory: "/tmp/"
volumes:
- /home/ricardo/Documents/Code/CSharp/Chiara/Chiara.Tests/Data:/data/music
labels:
- "traefik.enable=true"
- "traefik.http.routers.chiara.rule=Host(`chiara.jackfiled.icu`)"
- "traefik.http.services.chiara.loadbalancer.server.port=8080"
- "com.centurylinklabs.watchtower.enable=true"

View File

15
ChiaraAndroid/.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
ChiaraAndroid/.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@ -0,0 +1 @@
Chiara

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-06-10T03:24:59.011863144Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=3040223527000PT" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,41 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" />
</component>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
ChiaraAndroid/app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,152 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt.android)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.openapi.generator)
id("kotlin-kapt")
}
val openapiOutputDir: String = layout.buildDirectory.dir("generated/api").get().asFile.path
android {
namespace = "top.rrricardo.chiara"
compileSdk = 34
defaultConfig {
applicationId = "top.rrricardo.chiara"
minSdk = 28
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
sourceSets["main"].kotlin {
srcDir("$openapiOutputDir/src/main/kotlin")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Default
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.ktx)
// ViewModel utilities for Compose
implementation(libs.androidx.lifecycle.viewmodel.compose)
// LiveData
implementation(libs.androidx.lifecycle.livedata.ktx)
// Lifecycles only (without ViewModel or LiveData)
implementation(libs.androidx.lifecycle.runtime.ktx)
// Lifecycle utilities for Compose
implementation(libs.androidx.lifecycle.runtime.compose)
// Saved state module for ViewModel
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
// Annotation processor
kapt(libs.androidx.lifecycle.compiler)
// Navigation Controller
implementation(libs.androidx.navigation.compose)
// Palette
implementation(libs.androidx.palette)
// Media Exo Player
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.ui)
implementation(libs.media3.hls)
// Hilt
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
kapt(libs.hilt.android.compiler)
// Constraint Layout
implementation(libs.constraintlayout.compose)
// Coil
implementation(libs.coil)
// openapi
implementation(libs.okhttp3)
implementation(libs.retrofit.retrofit)
implementation(libs.retrofit.converter.scalars)
implementation(libs.retrofit.serialization.converter)
implementation(libs.kotlinx.serialization)
}
openApiGenerate {
generatorName.set("kotlin")
inputSpec.set("$rootDir/app/src/main/openapi/chiara.json")
outputDir.set(openapiOutputDir)
apiPackage.set("top.rrricardo.chiara.openapi.api")
modelPackage.set("top.rrricardo.chiara.openapi.model")
packageName.set("top.rrricardo.chiara.openapi.client")
generateApiTests.set(false)
generateModelTests.set(false)
configOptions.set(
mapOf(
"dataLibrary" to "java8"
)
)
additionalProperties.set(
mapOf(
"library" to "jvm-retrofit2",
"serializationLibrary" to "kotlinx_serialization",
"useCoroutines" to "true",
)
)
}

21
ChiaraAndroid/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package top.rrricardo.chiara
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("top.rrricardo.chiara", appContext.packageName)
}
}

Some files were not shown because too many files have changed in this diff Show More