CatFlac 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +88 -0
- data/assets/mascot.png +0 -0
- data/exe/catflac +22 -0
- data/lib/CatFlac/album.rb +21 -0
- data/lib/CatFlac/parsers/_base.rb +39 -0
- data/lib/CatFlac/parsers/_helpers.rb +39 -0
- data/lib/CatFlac/parsers/ai/request.rb +101 -0
- data/lib/CatFlac/parsers/ai_parser.rb +29 -0
- data/lib/CatFlac/parsers/builders/ai_album_builder.rb +44 -0
- data/lib/CatFlac/parsers/builders/cue_album_builder.rb +46 -0
- data/lib/CatFlac/parsers/chapters_parser.rb +21 -0
- data/lib/CatFlac/parsers/cue_parser.rb +32 -0
- data/lib/CatFlac/parsers/embedded_cue_parser.rb +37 -0
- data/lib/CatFlac/splitter.rb +50 -0
- data/lib/CatFlac/track.rb +23 -0
- data/lib/CatFlac/vendor/rubycue-master/LICENSE +26 -0
- data/lib/CatFlac/vendor/rubycue-master/lib/rubycue/cuesheet.rb +120 -0
- data/lib/CatFlac/vendor/rubycue-master/lib/rubycue/exceptions.rb +3 -0
- data/lib/CatFlac/vendor/rubycue-master/lib/rubycue/index.rb +108 -0
- data/lib/CatFlac/vendor/rubycue-master/lib/rubycue.rb +5 -0
- data/lib/CatFlac/version.rb +5 -0
- data/lib/cat_flac.rb +23 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4d1e4c8b93c0743c857ee7ffefc5881567bab443b6e714849875c9ae436002df
|
|
4
|
+
data.tar.gz: f3e6af35d05b0ce0389e26875ac3ce984c6312a828405713e1a66c64ce33866a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e8acfc5517a0af8d90850d9b756bc2f14118e987b06c7ad90f49e97bc97eb5128c356e9a8dfdfa9ae1d611cd8109656a758b745ffb1259abebdfffe760da44e9
|
|
7
|
+
data.tar.gz: 2af852e5b8d2fb962c6257ad50f1f5514c35a7758a25355ce28dd3d7edcf17d02519c7ef5419240ec1b7a75df4fbae20737517fecf16da2fefe19ff3ccd465c1
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our
|
|
6
|
+
community a harassment-free experience for everyone, regardless of age, body
|
|
7
|
+
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
8
|
+
identity and expression, level of experience, education, socio-economic status,
|
|
9
|
+
nationality, personal appearance, race, caste, color, religion, or sexual
|
|
10
|
+
identity and orientation.
|
|
11
|
+
|
|
12
|
+
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
13
|
+
diverse, inclusive, and healthy community.
|
|
14
|
+
|
|
15
|
+
## Our Standards
|
|
16
|
+
|
|
17
|
+
Examples of behavior that contributes to a positive environment for our
|
|
18
|
+
community include:
|
|
19
|
+
|
|
20
|
+
* Demonstrating empathy and kindness toward other people
|
|
21
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
|
22
|
+
* Giving and gracefully accepting constructive feedback
|
|
23
|
+
* Accepting responsibility and apologizing to those affected by our mistakes,
|
|
24
|
+
and learning from the experience
|
|
25
|
+
* Focusing on what is best not just for us as individuals, but for the overall
|
|
26
|
+
community
|
|
27
|
+
|
|
28
|
+
Examples of unacceptable behavior include:
|
|
29
|
+
|
|
30
|
+
* The use of sexualized language or imagery, and sexual attention or advances of
|
|
31
|
+
any kind
|
|
32
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
33
|
+
* Public or private harassment
|
|
34
|
+
* Publishing others' private information, such as a physical or email address,
|
|
35
|
+
without their explicit permission
|
|
36
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
|
37
|
+
professional setting
|
|
38
|
+
|
|
39
|
+
## Enforcement Responsibilities
|
|
40
|
+
|
|
41
|
+
Community leaders are responsible for clarifying and enforcing our standards of
|
|
42
|
+
acceptable behavior and will take appropriate and fair corrective action in
|
|
43
|
+
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
44
|
+
or harmful.
|
|
45
|
+
|
|
46
|
+
Community leaders have the right and responsibility to remove, edit, or reject
|
|
47
|
+
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
48
|
+
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
49
|
+
decisions when appropriate.
|
|
50
|
+
|
|
51
|
+
## Scope
|
|
52
|
+
|
|
53
|
+
This Code of Conduct applies within all community spaces, and also applies when
|
|
54
|
+
an individual is officially representing the community in public spaces.
|
|
55
|
+
Examples of representing our community include using an official email address,
|
|
56
|
+
posting via an official social media account, or acting as an appointed
|
|
57
|
+
representative at an online or offline event.
|
|
58
|
+
|
|
59
|
+
## Enforcement
|
|
60
|
+
|
|
61
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
62
|
+
reported to the community leaders responsible for enforcement at
|
|
63
|
+
[INSERT CONTACT METHOD].
|
|
64
|
+
All complaints will be reviewed and investigated promptly and fairly.
|
|
65
|
+
|
|
66
|
+
All community leaders are obligated to respect the privacy and security of the
|
|
67
|
+
reporter of any incident.
|
|
68
|
+
|
|
69
|
+
## Enforcement Guidelines
|
|
70
|
+
|
|
71
|
+
Community leaders will follow these Community Impact Guidelines in determining
|
|
72
|
+
the consequences for any action they deem in violation of this Code of Conduct:
|
|
73
|
+
|
|
74
|
+
### 1. Correction
|
|
75
|
+
|
|
76
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
77
|
+
unprofessional or unwelcome in the community.
|
|
78
|
+
|
|
79
|
+
**Consequence**: A private, written warning from community leaders, providing
|
|
80
|
+
clarity around the nature of the violation and an explanation of why the
|
|
81
|
+
behavior was inappropriate. A public apology may be requested.
|
|
82
|
+
|
|
83
|
+
### 2. Warning
|
|
84
|
+
|
|
85
|
+
**Community Impact**: A violation through a single incident or series of
|
|
86
|
+
actions.
|
|
87
|
+
|
|
88
|
+
**Consequence**: A warning with consequences for continued behavior. No
|
|
89
|
+
interaction with the people involved, including unsolicited interaction with
|
|
90
|
+
those enforcing the Code of Conduct, for a specified period of time. This
|
|
91
|
+
includes avoiding interactions in community spaces as well as external channels
|
|
92
|
+
like social media. Violating these terms may lead to a temporary or permanent
|
|
93
|
+
ban.
|
|
94
|
+
|
|
95
|
+
### 3. Temporary Ban
|
|
96
|
+
|
|
97
|
+
**Community Impact**: A serious violation of community standards, including
|
|
98
|
+
sustained inappropriate behavior.
|
|
99
|
+
|
|
100
|
+
**Consequence**: A temporary ban from any sort of interaction or public
|
|
101
|
+
communication with the community for a specified period of time. No public or
|
|
102
|
+
private interaction with the people involved, including unsolicited interaction
|
|
103
|
+
with those enforcing the Code of Conduct, is allowed during this period.
|
|
104
|
+
Violating these terms may lead to a permanent ban.
|
|
105
|
+
|
|
106
|
+
### 4. Permanent Ban
|
|
107
|
+
|
|
108
|
+
**Community Impact**: Demonstrating a pattern of violation of community
|
|
109
|
+
standards, including sustained inappropriate behavior, harassment of an
|
|
110
|
+
individual, or aggression toward or disparagement of classes of individuals.
|
|
111
|
+
|
|
112
|
+
**Consequence**: A permanent ban from any sort of public interaction within the
|
|
113
|
+
community.
|
|
114
|
+
|
|
115
|
+
## Attribution
|
|
116
|
+
|
|
117
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
118
|
+
version 2.1, available at
|
|
119
|
+
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
|
120
|
+
|
|
121
|
+
Community Impact Guidelines were inspired by
|
|
122
|
+
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
|
123
|
+
|
|
124
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
|
125
|
+
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
|
126
|
+
[https://www.contributor-covenant.org/translations][translations].
|
|
127
|
+
|
|
128
|
+
[homepage]: https://www.contributor-covenant.org
|
|
129
|
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
|
130
|
+
[Mozilla CoC]: https://github.com/mozilla/diversity
|
|
131
|
+
[FAQ]: https://www.contributor-covenant.org/faq
|
|
132
|
+
[translations]: https://www.contributor-covenant.org/translations
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Acht
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# 🧶 CatFlac
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="assets/mascot.png" alt="CatFlac Mascot" width="200">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
>
|
|
9
|
+
> CatFlac is a simple tool for splitting large audio files. It's currently in its initial stage - functional, but probably needs some improvements.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Current Skills
|
|
14
|
+
|
|
15
|
+
* **CUE Support**: Handles basic external and embedded CUE sheets.
|
|
16
|
+
* **Experimental AI**: A curious attempt at track identification (requires `AI_API_KEY`).
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 🐾 Getting Started
|
|
22
|
+
|
|
23
|
+
First of all you need to install ffmpeg on your system.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
sudo apt install ffmpeg
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then install the gem:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
gem install CatFlac
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or add this to your application's `Gemfile`:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
gem 'CatFlac'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
And then run:
|
|
42
|
+
```bash
|
|
43
|
+
bundle install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Using CatFlac is pretty straightforward.
|
|
49
|
+
|
|
50
|
+
### Command Line Interface
|
|
51
|
+
|
|
52
|
+
Just point CatFlac to a folder containing your audio files:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
CatFlac split /path/to/your/music_folder
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Ruby API
|
|
59
|
+
|
|
60
|
+
Integrating CatFlac into your project:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
require 'CatFlac'
|
|
64
|
+
|
|
65
|
+
CatFlac.cat!('/path/to/your/music_folder')
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Experimental AI Mode
|
|
69
|
+
|
|
70
|
+
If you don't have a CUE sheet, you can try letting CatFlac guess the tracks. To do so, set perplexity api key as an environment variable:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
export AI_API_KEY='api_key_here'
|
|
74
|
+
|
|
75
|
+
CatFlac split /path/to/mysterious_album
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Contributing
|
|
79
|
+
|
|
80
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/lxndr128/CatFlac](https://github.com/lxndr128/CatFlac)
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
This gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
|
data/assets/mascot.png
ADDED
|
Binary file
|
data/exe/catflac
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "cat_flac"
|
|
5
|
+
require "thor"
|
|
6
|
+
class CatFlacCLI < Thor
|
|
7
|
+
def self.exit_on_failure?
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
desc "cat FILE_PATH", "Split a FLAC file on tracks"
|
|
11
|
+
def cat(file_path)
|
|
12
|
+
CatFlac.cat!(file_path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "cut FILE_PATH", "Split a FLAC file on tracks"
|
|
16
|
+
alias_method :cut, :cat
|
|
17
|
+
|
|
18
|
+
desc "split FILE_PATH", "Split a FLAC file on tracks"
|
|
19
|
+
alias_method :split, :cat
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
CatFlacCLI.start(ARGV)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CatFlac
|
|
4
|
+
class Album
|
|
5
|
+
attr_reader :title, :artist, :year, :source_file, :tracks, :release_date, :cover
|
|
6
|
+
|
|
7
|
+
def initialize(title:, artist:, cover:, release_date:, genre:, source_file: nil, tracks: [])
|
|
8
|
+
@title = title
|
|
9
|
+
@artist = artist
|
|
10
|
+
@source_file = source_file
|
|
11
|
+
@tracks = tracks
|
|
12
|
+
@cover = cover
|
|
13
|
+
@genre = genre
|
|
14
|
+
@release_date = release_date
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add_track(track)
|
|
18
|
+
@tracks << track
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CatFlac
|
|
4
|
+
module Parsers
|
|
5
|
+
class Base
|
|
6
|
+
AUDIO_EXTENSIONS = %w[
|
|
7
|
+
flac wav ape wv m4a mp3 ogg opus tta tak aiff aif m4b
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
COVER_EXTENTIONS = %w[
|
|
11
|
+
img jpg png jpeg
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :path
|
|
15
|
+
|
|
16
|
+
def self.parse(target_folder_path:)
|
|
17
|
+
@path = target_folder_path
|
|
18
|
+
select_parser.new(target_folder_path).parse
|
|
19
|
+
rescue ParserError
|
|
20
|
+
raise
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
raise ParserError, "Unexpected error during parsing: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.select_parser
|
|
26
|
+
parsers = subclasses.sort_by { |klass| klass::ORDER }
|
|
27
|
+
parsers.each do |parser_class|
|
|
28
|
+
return parser_class if parser_class.match?(@path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
raise NoMatchingParserError, "No suitable parser found for path: #{@path}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.match?
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CatFlac
|
|
4
|
+
module Parsers
|
|
5
|
+
module Helpers
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def read_cue_content(cue_path)
|
|
9
|
+
File.read(cue_path, encoding: "UTF-8")
|
|
10
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
11
|
+
File.read(cue_path, encoding: "Windows-1251").encode("UTF-8")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get_audio_duration(path)
|
|
15
|
+
require "streamio-ffmpeg"
|
|
16
|
+
duration = FFMPEG::Movie.new(path).duration
|
|
17
|
+
Time.at(duration).utc.strftime("%M:%S:%L")
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
raise CatFlac::Error, "Failed to analyze audio file: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def time_to_seconds(time)
|
|
23
|
+
time_str = time.to_s
|
|
24
|
+
return if time_str.empty?
|
|
25
|
+
|
|
26
|
+
minutes, seconds, frames = time_str.split(":").map(&:to_i)
|
|
27
|
+
|
|
28
|
+
(minutes * 60) + seconds + (frames / 75.0)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def find_cover(extensions, path)
|
|
32
|
+
imgs = Dir.glob(File.join(path, "*.{#{extensions.join(',')}}"))
|
|
33
|
+
return unless imgs.one?
|
|
34
|
+
|
|
35
|
+
imgs.first
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
|
|
6
|
+
module CatFlac
|
|
7
|
+
module Parsers
|
|
8
|
+
module AI
|
|
9
|
+
module Request
|
|
10
|
+
PROMPT = <<-TEXT
|
|
11
|
+
You are a music metadata expert. Use MusicBrainz or Discogs, and ONLY in case you can't find
|
|
12
|
+
anything there use another sources.
|
|
13
|
+
|
|
14
|
+
1. Identify the album or albums from these files
|
|
15
|
+
2. Find the EXACT official tracklist with durations (MM:SS:FF format)
|
|
16
|
+
3. Match files to tracks by order and duration
|
|
17
|
+
4. Return accurate data from the database, not the files
|
|
18
|
+
5. USE PROVIDED SCHEMA! STRICTLY. THERE IS COMPLETED DESCRIPTION OF DATA WHAT WE NEED IN THIS SCHEMA
|
|
19
|
+
6. Check output json on syntax errors.
|
|
20
|
+
7. Be carefull, some releases could be described as one big track, but still contain a few different tracks inside.
|
|
21
|
+
We want to split it anyway, don't care about artistic obstacles
|
|
22
|
+
|
|
23
|
+
TEXT
|
|
24
|
+
|
|
25
|
+
RESPONSE_SCHEMA = {
|
|
26
|
+
albums: [
|
|
27
|
+
{
|
|
28
|
+
source_file: { type: "string", description: "Relative path to the music file" },
|
|
29
|
+
title: { type: "string" },
|
|
30
|
+
artist: { type: "string" },
|
|
31
|
+
cover: { type: "string", description: "Relative path to the cover file" },
|
|
32
|
+
genre: { type: "string" },
|
|
33
|
+
release_date: { type: "string" },
|
|
34
|
+
tracks: [
|
|
35
|
+
{
|
|
36
|
+
cover: { type: %w[string null], description: "Only in case of separate covers for each track" },
|
|
37
|
+
number: { type: "string" },
|
|
38
|
+
title: { type: "string" },
|
|
39
|
+
artist: { type: "string" },
|
|
40
|
+
duration: { type: "string (CUE format 75 frames)", format: "mm:ss:ff" }
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
def make_request(files, formats)
|
|
50
|
+
return JSON.parse(File.read("mock.json")) if File.exist?("mock.json")
|
|
51
|
+
|
|
52
|
+
uri = URI("https://api.perplexity.ai/v1/responses")
|
|
53
|
+
|
|
54
|
+
request = Net::HTTP::Post.new(uri)
|
|
55
|
+
request["Authorization"] = "Bearer #{ENV.fetch('AI_API_KEY', nil)}"
|
|
56
|
+
request["Content-Type"] = "application/json"
|
|
57
|
+
|
|
58
|
+
request.body = body(files, formats).to_json
|
|
59
|
+
|
|
60
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 300) do |https|
|
|
61
|
+
https.request(request)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
65
|
+
raise APIError, "AI API request failed: #{response.code} #{response.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
text = JSON.parse(response.body).dig("output", -1, "content", -1, "text")
|
|
69
|
+
JSON.parse(text)["albums"]
|
|
70
|
+
rescue JSON::ParserError => e
|
|
71
|
+
raise ParserError, "Failed to parse AI response: #{e.message}"
|
|
72
|
+
rescue Net::HTTPError, SocketError, Timeout::Error => e
|
|
73
|
+
raise APIError, "Network error during AI request: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def body(files, formats)
|
|
77
|
+
content = "#{PROMPT}\nFILES:\n#{files}\nMETADATA:\n#{formats}Respone shema#{RESPONSE_SCHEMA.to_json}"
|
|
78
|
+
{
|
|
79
|
+
input: [
|
|
80
|
+
{
|
|
81
|
+
type: "message",
|
|
82
|
+
role: "system",
|
|
83
|
+
content: content
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
instructions: "Use web_search to find exact metadata from MusicBrainz/Discogs. Never guess.",
|
|
87
|
+
model: "perplexity/sonar",
|
|
88
|
+
tools: [
|
|
89
|
+
{
|
|
90
|
+
type: "web_search",
|
|
91
|
+
filters: {
|
|
92
|
+
search_domain_filter: ["musicbrainz.org", "discogs.com", "youtube.com"]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CatFlac
|
|
4
|
+
module Parsers
|
|
5
|
+
class AIParser < Base
|
|
6
|
+
ORDER = 3
|
|
7
|
+
|
|
8
|
+
def self.match?(path)
|
|
9
|
+
return false unless ENV["AI_API_KEY"]
|
|
10
|
+
|
|
11
|
+
Dir.glob(File.join(path, "*.{#{AUDIO_EXTENSIONS.join(',')}}")).any?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(path)
|
|
15
|
+
formats = Dir.glob(File.join(path, "**/*.{#{AUDIO_EXTENSIONS.join(',')}}")).map do |path_|
|
|
16
|
+
`ffprobe -v quiet -print_format json -show_format "#{path_}"`
|
|
17
|
+
end.join("\n")
|
|
18
|
+
|
|
19
|
+
files = Dir.glob(File.join(path, "*")).join("\n")
|
|
20
|
+
|
|
21
|
+
@albums = AI::Request.make_request(files, formats)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse
|
|
25
|
+
@albums.map { |album| Builders::AiAlbumBuilder.make_album(album) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CatFlac
|
|
4
|
+
module Parsers
|
|
5
|
+
module Builders
|
|
6
|
+
module AiAlbumBuilder
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def make_album(album_data)
|
|
10
|
+
album = CatFlac::Album.new(
|
|
11
|
+
title: album_data["title"],
|
|
12
|
+
artist: album_data["artist"],
|
|
13
|
+
source_file: album_data["source_file"],
|
|
14
|
+
genre: album_data["genre"],
|
|
15
|
+
cover: album_data["cover"],
|
|
16
|
+
release_date: album_data["release_date"]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
start_time = 0
|
|
20
|
+
|
|
21
|
+
album_data["tracks"].each_with_index do |track_data, index|
|
|
22
|
+
duration = Helpers.time_to_seconds(track_data["duration"])
|
|
23
|
+
|
|
24
|
+
track = CatFlac::Track.new(
|
|
25
|
+
album: album_data["title"],
|
|
26
|
+
number: (track_data["number"] || (index + 1)).to_i,
|
|
27
|
+
title: track_data["title"],
|
|
28
|
+
artist: track_data["artist"],
|
|
29
|
+
start_time: start_time,
|
|
30
|
+
duration: duration,
|
|
31
|
+
genre: album_data["genre"]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
start_time += duration
|
|
35
|
+
|
|
36
|
+
album.add_track(track)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
album
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CatFlac
|
|
4
|
+
module Parsers
|
|
5
|
+
module Builders
|
|
6
|
+
module CueAlbumBuilder
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def make_album(cuesheet, audio_path, path)
|
|
10
|
+
album = CatFlac::Album.new(
|
|
11
|
+
title: cuesheet.title,
|
|
12
|
+
artist: cuesheet.performer,
|
|
13
|
+
source_file: audio_path,
|
|
14
|
+
genre: cuesheet.genre,
|
|
15
|
+
cover: Helpers.find_cover(Base::COVER_EXTENTIONS, path),
|
|
16
|
+
release_date: cuesheet.year
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
total_duration = Helpers.get_audio_duration(audio_path)
|
|
20
|
+
|
|
21
|
+
cuesheet.songs.each_with_index do |song_data, index|
|
|
22
|
+
start_time = Helpers.time_to_seconds(song_data[:index].to_s)
|
|
23
|
+
next_song = cuesheet.songs[index + 1]
|
|
24
|
+
end_time = Helpers.time_to_seconds(next_song&.[](:index) || total_duration)
|
|
25
|
+
duration = end_time - start_time
|
|
26
|
+
duration = 0 if duration.negative?
|
|
27
|
+
|
|
28
|
+
track = CatFlac::Track.new(
|
|
29
|
+
album: album,
|
|
30
|
+
number: (song_data[:track] || (index + 1)).to_i,
|
|
31
|
+
title: song_data[:title],
|
|
32
|
+
artist: song_data[:performer],
|
|
33
|
+
start_time: start_time,
|
|
34
|
+
duration: duration,
|
|
35
|
+
genre: cuesheet.genre
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
album.add_track(track)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
album
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CatFlac
|
|
4
|
+
module Parsers
|
|
5
|
+
# TODO
|
|
6
|
+
class ChaptersParser < Base
|
|
7
|
+
ORDER = 2
|
|
8
|
+
# `ffprobe -v quiet -print_format json -show_format -show_streams -show_chapters "#{@audio_path}"`
|
|
9
|
+
def self.match?(_path)
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def extract_embedded_chapters(file)
|
|
16
|
+
cmd = `ffprobe -v quiet -print_format json -show_format -show_chapters \"#{file}\"`
|
|
17
|
+
JSON.parse(cmd)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../vendor/rubycue-master/lib/rubycue"
|
|
4
|
+
|
|
5
|
+
module CatFlac
|
|
6
|
+
module Parsers
|
|
7
|
+
class CueParser < Base
|
|
8
|
+
ORDER = 0
|
|
9
|
+
|
|
10
|
+
def self.match?(path)
|
|
11
|
+
return false unless Dir.glob(File.join(path, "*.cue")).one?
|
|
12
|
+
|
|
13
|
+
Dir.glob(File.join(path, "*.{#{AUDIO_EXTENSIONS.join(',')}}")).one?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(path)
|
|
17
|
+
@path = path
|
|
18
|
+
cue_path = Dir.glob(File.join(path, "*.cue")).first
|
|
19
|
+
@audio_path = Dir.glob(File.join(path, "*.{#{AUDIO_EXTENSIONS.join(',')}}")).first
|
|
20
|
+
begin
|
|
21
|
+
@cuesheet = ::RubyCue::Cuesheet.new(Helpers.read_cue_content(cue_path)).tap(&:parse!)
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
raise ParserError, "Failed to parse cuesheet #{cue_path}: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse
|
|
28
|
+
[Builders::CueAlbumBuilder.make_album(@cuesheet, @audio_path, @path)]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
module CatFlac
|
|
5
|
+
module Parsers
|
|
6
|
+
class EmbeddedCueParser < CueParser
|
|
7
|
+
ORDER = 1
|
|
8
|
+
|
|
9
|
+
def self.match?(path)
|
|
10
|
+
return false unless Dir.glob(File.join(path, "*.cue")).none?
|
|
11
|
+
|
|
12
|
+
files = Dir.glob(File.join(path, "*.{#{AUDIO_EXTENSIONS.join(',')}}"))
|
|
13
|
+
return false unless files.one?
|
|
14
|
+
|
|
15
|
+
extract_embedded_cuesheet(files.last).present?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(path)
|
|
19
|
+
@audio_path = Dir.glob(File.join(path, "*.{#{AUDIO_EXTENSIONS.join(',')}}")).first
|
|
20
|
+
cue = self.class.extract_embedded_cuesheet(@audio_path)
|
|
21
|
+
@cuesheet = ::RubyCue::Cuesheet.new(cue).tap(&:parse!)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.extract_embedded_cuesheet(file)
|
|
25
|
+
cmd = "ffprobe -v quiet -print_format json -show_format \"#{file}\""
|
|
26
|
+
output = `#{cmd}`
|
|
27
|
+
|
|
28
|
+
raise CommandError, "Failed to run ffprobe: #{$CHILD_STATUS.exitstatus}" unless $CHILD_STATUS.success?
|
|
29
|
+
|
|
30
|
+
tags = JSON.parse(output).dig("format", "tags") || {}
|
|
31
|
+
tags[tags.keys.find { |k| k.upcase == "CUESHEET" }]
|
|
32
|
+
rescue JSON::ParserError => e
|
|
33
|
+
raise ParserError, "Failed to parse ffprobe output: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module CatFlac
|
|
6
|
+
class Splitter
|
|
7
|
+
def self.split(album)
|
|
8
|
+
FileUtils.mkdir_p(album.title)
|
|
9
|
+
ext = File.extname(album.source_file)
|
|
10
|
+
|
|
11
|
+
album.tracks.each do |track|
|
|
12
|
+
output_file = File.join(album.title, (track.filename + ext).to_s)
|
|
13
|
+
|
|
14
|
+
cmd = [
|
|
15
|
+
"ffmpeg",
|
|
16
|
+
"-y",
|
|
17
|
+
"-i", album.source_file
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
cmd += ["-i", album.cover] if album.cover
|
|
21
|
+
|
|
22
|
+
cmd += [
|
|
23
|
+
"-ss", track.start_time.to_s,
|
|
24
|
+
"-t", track.duration.to_s
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
if album.cover
|
|
28
|
+
cmd += ["-map", "0:a", "-map", "1:0"]
|
|
29
|
+
cmd += ["-c:v", "mjpeg"]
|
|
30
|
+
cmd += ["-disposition:v", "attached_pic"]
|
|
31
|
+
else
|
|
32
|
+
cmd += ["-map", "0:a"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
cmd += [
|
|
36
|
+
"-metadata", "title=#{track.title}",
|
|
37
|
+
"-metadata", "artist=#{track.artist}",
|
|
38
|
+
"-metadata", "track=#{track.number}",
|
|
39
|
+
"-metadata", "genre=#{track.genre}",
|
|
40
|
+
"-metadata", "album=#{album.title}"
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
cmd << output_file
|
|
44
|
+
|
|
45
|
+
ok = system(*cmd)
|
|
46
|
+
raise CommandError, "ffmpeg failed for #{output_file}" unless ok
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CatFlac
|
|
4
|
+
class Track
|
|
5
|
+
attr_reader :album, :cover, :number, :title, :artist, :start_time, :duration, :genre
|
|
6
|
+
|
|
7
|
+
def initialize(album:, number:, title:, artist:, start_time: nil, duration: nil, genre: nil, cover: nil)
|
|
8
|
+
@album = album
|
|
9
|
+
@cover = cover
|
|
10
|
+
@number = number
|
|
11
|
+
@title = title
|
|
12
|
+
@artist = artist
|
|
13
|
+
@start_time = start_time
|
|
14
|
+
@duration = duration
|
|
15
|
+
@genre = genre
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def filename
|
|
19
|
+
safe_title = title.gsub(%r{[/\\]}, "-")
|
|
20
|
+
"#{number.to_s.rjust(2, '0')}. #{artist} - #{safe_title}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
author: Blake Smith
|
|
2
|
+
description: Basic ruby parser for song cuesheets
|
|
3
|
+
email: blakesmith0@gmail.com
|
|
4
|
+
homepage: http://github.com/blakesmith/rubycue
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2010 Blake Smith
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
9
|
+
a copy of this software and associated documentation files (the
|
|
10
|
+
"Software"), to deal in the Software without restriction, including
|
|
11
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
12
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
13
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
14
|
+
the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be
|
|
17
|
+
included in all copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
20
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
21
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
22
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
23
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
24
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
25
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
26
|
+
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
module RubyCue
|
|
2
|
+
class Cuesheet
|
|
3
|
+
attr_reader :cuesheet, :songs, :track_duration, :performer, :title, :file, :genre, :year
|
|
4
|
+
|
|
5
|
+
def initialize(cuesheet, track_duration=nil)
|
|
6
|
+
@cuesheet = cuesheet
|
|
7
|
+
@reg = {
|
|
8
|
+
:track => %r(TRACK (\d{1,3}) AUDIO),
|
|
9
|
+
:performer => %r(PERFORMER "(.*)"),
|
|
10
|
+
:title => %r(TITLE "(.*)"),
|
|
11
|
+
:index => %r(INDEX \d{1,3} (\d{1,3}):(\d{1,2}):(\d{1,2})),
|
|
12
|
+
:file => %r(FILE "(.*)"),
|
|
13
|
+
:genre => %r(REM GENRE (.*)\b),
|
|
14
|
+
:year => %r(YEAR "(.*)")
|
|
15
|
+
}
|
|
16
|
+
@track_duration = RubyCue::Index.new(track_duration) if track_duration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse!
|
|
20
|
+
@songs = parse_titles.map{|title| {:title => title}}
|
|
21
|
+
@songs.each_with_index do |song, i|
|
|
22
|
+
song[:performer] = parse_performers[i]
|
|
23
|
+
song[:track] = parse_tracks[i]
|
|
24
|
+
song[:index] = parse_indices[i]
|
|
25
|
+
song[:file] = parse_files[i]
|
|
26
|
+
end
|
|
27
|
+
parse_genre
|
|
28
|
+
parse_year
|
|
29
|
+
raise RubyCue::InvalidCuesheet.new("Field amounts are not all present. Cuesheet is malformed!") unless valid?
|
|
30
|
+
calculate_song_durations!
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def position(value)
|
|
35
|
+
index = Index.new(value)
|
|
36
|
+
return @songs.first if index < @songs.first[:index]
|
|
37
|
+
@songs.each_with_index do |song, i|
|
|
38
|
+
return song if song == @songs.last
|
|
39
|
+
return song if between(song[:index], @songs[i+1][:index], index)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def valid?
|
|
44
|
+
@songs.all? do |song|
|
|
45
|
+
[:performer, :track, :index, :title].all? do |key|
|
|
46
|
+
song[key] != nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def calculate_song_durations!
|
|
54
|
+
@songs.each_with_index do |song, i|
|
|
55
|
+
if song == @songs.last
|
|
56
|
+
song[:duration] = (@track_duration - song[:index]) if @track_duration
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
song[:duration] = @songs[i+1][:index] - song[:index]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def between(a, b, position_index)
|
|
64
|
+
(position_index > a) && (position_index < b)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_titles
|
|
68
|
+
unless @titles
|
|
69
|
+
@titles = cuesheet_scan(:title).map{|title| title.first}
|
|
70
|
+
@title = @titles.delete_at(0)
|
|
71
|
+
end
|
|
72
|
+
@titles
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_performers
|
|
76
|
+
unless @performers
|
|
77
|
+
@performers = cuesheet_scan(:performer).map{|performer| performer.first}
|
|
78
|
+
@performer = @performers.delete_at(0)
|
|
79
|
+
end
|
|
80
|
+
@performers
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_tracks
|
|
84
|
+
@tracks ||= cuesheet_scan(:track).map{|track| track.first.to_i}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_indices
|
|
88
|
+
@indices ||= cuesheet_scan(:index).map{|index| RubyCue::Index.new([index[0].to_i, index[1].to_i, index[2].to_i])}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_files
|
|
92
|
+
unless @files
|
|
93
|
+
@files = cuesheet_scan(:file).map{|file| file.first}
|
|
94
|
+
@file = @files.delete_at(0) if @files.size == 1
|
|
95
|
+
end
|
|
96
|
+
@files
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_genre
|
|
100
|
+
@cuesheet.scan(@reg[:genre]) do |genre|
|
|
101
|
+
@genre = genre.first
|
|
102
|
+
break
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_year
|
|
107
|
+
@cuesheet.scan(@reg[:year]) do |year|
|
|
108
|
+
@year = year.first
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def cuesheet_scan(field)
|
|
114
|
+
scan = @cuesheet.scan(@reg[field])
|
|
115
|
+
raise InvalidCuesheet.new("No fields were found for #{field.to_s}") if scan.empty?
|
|
116
|
+
scan
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module RubyCue
|
|
2
|
+
class Index
|
|
3
|
+
SECONDS_PER_MINUTE = 60
|
|
4
|
+
FRAMES_PER_SECOND = 75
|
|
5
|
+
FRAMES_PER_MINUTE = FRAMES_PER_SECOND * 60
|
|
6
|
+
|
|
7
|
+
attr_reader :minutes, :seconds, :frames
|
|
8
|
+
|
|
9
|
+
def initialize(value=nil)
|
|
10
|
+
case value
|
|
11
|
+
when Array
|
|
12
|
+
set_from_array!(value)
|
|
13
|
+
when Integer
|
|
14
|
+
set_from_integer!(value)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_f
|
|
19
|
+
((@minutes * SECONDS_PER_MINUTE) + (@seconds) + (@frames.to_f / FRAMES_PER_SECOND)).to_f
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_i
|
|
23
|
+
to_f.floor
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_a
|
|
27
|
+
[@minutes, @seconds, @frames]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_s
|
|
31
|
+
"#{'%02d' % @minutes}:#{'%02d' % @seconds}:#{'%02d' % @frames}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def +(other)
|
|
35
|
+
self.class.new(carrying_addition(other))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def -(other)
|
|
39
|
+
self.class.new(carrying_subtraction(other))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def >(other)
|
|
43
|
+
self.to_f > other.to_f
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def <(other)
|
|
47
|
+
self.to_f < other.to_f
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ==(other)
|
|
51
|
+
self.to_a == other.to_a
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def each
|
|
55
|
+
to_a.each {|value| yield value }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def carrying_addition(other)
|
|
61
|
+
minutes, seconds, frames = *[@minutes + other.minutes,
|
|
62
|
+
@seconds + other.seconds, @frames + other.frames]
|
|
63
|
+
|
|
64
|
+
seconds, frames = *convert_with_rate(frames, seconds, FRAMES_PER_SECOND)
|
|
65
|
+
minutes, seconds = *convert_with_rate(seconds, minutes, SECONDS_PER_MINUTE)
|
|
66
|
+
[minutes, seconds, frames]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def carrying_subtraction(other)
|
|
70
|
+
seconds = minutes = 0
|
|
71
|
+
|
|
72
|
+
my_frames = @frames + (@seconds * FRAMES_PER_SECOND) + (@minutes * FRAMES_PER_MINUTE)
|
|
73
|
+
other_frames = other.frames + (other.seconds * FRAMES_PER_SECOND) + (other.minutes * FRAMES_PER_MINUTE)
|
|
74
|
+
frames = my_frames - other_frames
|
|
75
|
+
|
|
76
|
+
seconds, frames = *convert_with_rate(frames, seconds, FRAMES_PER_SECOND)
|
|
77
|
+
minutes, seconds = *convert_with_rate(seconds, minutes, SECONDS_PER_MINUTE)
|
|
78
|
+
[minutes, seconds, frames]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def convert_with_rate(from, to, rate, step=1)
|
|
82
|
+
while from >= rate
|
|
83
|
+
to += step
|
|
84
|
+
from -= rate
|
|
85
|
+
end
|
|
86
|
+
[to, from]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def set_from_array!(array)
|
|
90
|
+
if array.size != 3 || array.any?{|element| !element.is_a?(Integer)}
|
|
91
|
+
raise ArgumentError.new("Must be initialized with an array in the format of [minutes, seconds,frames], all integers")
|
|
92
|
+
end
|
|
93
|
+
@minutes, @seconds, @frames = *array
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def set_from_integer!(seconds)
|
|
97
|
+
@minutes = 0
|
|
98
|
+
@frames = 0
|
|
99
|
+
@seconds = seconds
|
|
100
|
+
|
|
101
|
+
while @seconds >= SECONDS_PER_MINUTE
|
|
102
|
+
@minutes += 1
|
|
103
|
+
@seconds -= SECONDS_PER_MINUTE
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/cat_flac.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Dir[File.join(__dir__, "CatFlac/**/*.rb")].sort.each { |file| require file }
|
|
4
|
+
|
|
5
|
+
module CatFlac
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class ParserError < Error; end
|
|
8
|
+
class NoMatchingParserError < ParserError; end
|
|
9
|
+
class APIError < Error; end
|
|
10
|
+
class CommandError < Error; end
|
|
11
|
+
|
|
12
|
+
# Meow-meow
|
|
13
|
+
def self.cat!(path)
|
|
14
|
+
albums = Parsers::Base.parse(target_folder_path: path)
|
|
15
|
+
albums.each { |album| Splitter.split(album) }
|
|
16
|
+
rescue NoMatchingParserError => e
|
|
17
|
+
warn(e.message)
|
|
18
|
+
rescue CommandError, APIError => e
|
|
19
|
+
warn("External dependency failed: #{e.message}")
|
|
20
|
+
rescue ParserError => e
|
|
21
|
+
warn("Parsing failed: #{e.message}")
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: CatFlac
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Aleksandr Akhtyrskii
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: streamio-ffmpeg
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: thor
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.3'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.3'
|
|
41
|
+
description: CatFlac is an utility that helps you split large lossless audio files
|
|
42
|
+
into tracks using CUE sheets or experimental AI identification. Still in early development.
|
|
43
|
+
email:
|
|
44
|
+
- alexakhtyrskii@gmail.com
|
|
45
|
+
executables:
|
|
46
|
+
- catflac
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- CODE_OF_CONDUCT.md
|
|
52
|
+
- LICENSE.txt
|
|
53
|
+
- README.md
|
|
54
|
+
- assets/mascot.png
|
|
55
|
+
- exe/catflac
|
|
56
|
+
- lib/CatFlac/album.rb
|
|
57
|
+
- lib/CatFlac/parsers/_base.rb
|
|
58
|
+
- lib/CatFlac/parsers/_helpers.rb
|
|
59
|
+
- lib/CatFlac/parsers/ai/request.rb
|
|
60
|
+
- lib/CatFlac/parsers/ai_parser.rb
|
|
61
|
+
- lib/CatFlac/parsers/builders/ai_album_builder.rb
|
|
62
|
+
- lib/CatFlac/parsers/builders/cue_album_builder.rb
|
|
63
|
+
- lib/CatFlac/parsers/chapters_parser.rb
|
|
64
|
+
- lib/CatFlac/parsers/cue_parser.rb
|
|
65
|
+
- lib/CatFlac/parsers/embedded_cue_parser.rb
|
|
66
|
+
- lib/CatFlac/splitter.rb
|
|
67
|
+
- lib/CatFlac/track.rb
|
|
68
|
+
- lib/CatFlac/vendor/rubycue-master/LICENSE
|
|
69
|
+
- lib/CatFlac/vendor/rubycue-master/lib/rubycue.rb
|
|
70
|
+
- lib/CatFlac/vendor/rubycue-master/lib/rubycue/cuesheet.rb
|
|
71
|
+
- lib/CatFlac/vendor/rubycue-master/lib/rubycue/exceptions.rb
|
|
72
|
+
- lib/CatFlac/vendor/rubycue-master/lib/rubycue/index.rb
|
|
73
|
+
- lib/CatFlac/version.rb
|
|
74
|
+
- lib/cat_flac.rb
|
|
75
|
+
homepage: https://github.com/lxndr128/CatFlac
|
|
76
|
+
licenses:
|
|
77
|
+
- MIT
|
|
78
|
+
metadata:
|
|
79
|
+
homepage_uri: https://github.com/lxndr128/CatFlac
|
|
80
|
+
source_code_uri: https://github.com/lxndr128/CatFlac
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
changelog_uri: https://github.com/lxndr128/CatFlac/blob/main/CHANGELOG.md
|
|
83
|
+
post_install_message:
|
|
84
|
+
rdoc_options: []
|
|
85
|
+
require_paths:
|
|
86
|
+
- lib
|
|
87
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: 3.1.0
|
|
92
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0'
|
|
97
|
+
requirements: []
|
|
98
|
+
rubygems_version: 3.5.3
|
|
99
|
+
signing_key:
|
|
100
|
+
specification_version: 4
|
|
101
|
+
summary: A simple tool for splitting lossless audio files.
|
|
102
|
+
test_files: []
|