Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2542d51
feat(config): add channel_folder_template to storage templates
fllppi Feb 24, 2026
6a1d929
feat(storage): add shared storagetemplate package and extend archive …
fllppi Feb 24, 2026
3b73739
feat(archive): use channel folder template in archive operations
fllppi Feb 24, 2026
46bde0d
feat(tasks): use channel folder template in task operations
fllppi Feb 24, 2026
414d0c2
feat(channel): use channel folder template in UpdateChannelImage
fllppi Feb 24, 2026
ff738a8
feat(task): add channel folder migration to StorageMigration
fllppi Feb 24, 2026
a792cea
fix(database): improve VideosDirMigrate for custom channel folders
fllppi Feb 24, 2026
77527c4
test(archive): add tests for GetChannelFolderName and new template va…
fllppi Feb 24, 2026
a85af59
feat(frontend): add channel folder template UI to admin settings
fllppi Feb 24, 2026
1a9cf6b
fix(security): sanitize template output to prevent path traversal
fllppi Feb 24, 2026
c4c43a4
fix(task): fix StorageMigration bugs and add safety checks
fllppi Feb 24, 2026
d8e2687
test(archive): add path traversal and sanitization test cases
fllppi Feb 24, 2026
2d0b69f
fix: correct test expectations and pre-sanitize channel display name
fllppi Feb 24, 2026
97c626c
fix(storage): reject empty template variable values to prevent folder…
fllppi Feb 24, 2026
2fc58c5
fix(tasks): derive sprite thumbnail path from stored video path
fllppi Feb 24, 2026
5297d9a
docs: add docstrings to storage template functions for coverage thres…
fllppi Feb 24, 2026
7ecebc0
fix(security): sanitize GetFolderName and GetFileName output and defe…
fllppi Feb 24, 2026
8deff87
fix(security): check variable emptiness before substitution, not afte…
fllppi Feb 24, 2026
bfcea86
Merge branch 'main' into feature/1019-channel-folder-template
fllppi Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions frontend/app/admin/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const AdminSettingsPage = () => {
storage_templates: {
folder_template: data?.storage_templates.folder_template || "",
file_template: data?.storage_templates.file_template || "",
channel_folder_template: data?.storage_templates.channel_folder_template || "{{channel}}",
},
livestream: {
proxies: data?.livestream.proxies || [],
Expand All @@ -67,8 +68,16 @@ const AdminSettingsPage = () => {
useEffect(() => {
if (!data || !form) return

form.setValues(data)
form.resetDirty(data)
const dataWithDefaults = {
...data,
storage_templates: {
...data.storage_templates,
channel_folder_template: data.storage_templates.channel_folder_template || "{{channel}}",
},
}

form.setValues(dataWithDefaults)
form.resetDirty(dataWithDefaults)

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
Expand Down Expand Up @@ -191,8 +200,20 @@ const AdminSettingsPage = () => {
<Text mb={10}>
{t('archiveSettings.storageTemplateSettingsDescription')}
</Text>

<div>
<Title order={4}>Channel Folder Template</Title>

<Textarea
description="Controls the top-level channel directory name. Use {{channel_id}} for a stable identifier that won't change when a channel renames."
key={form.key('storage_templates.channel_folder_template')}
{...form.getInputProps('storage_templates.channel_folder_template')}
required
/>
</div>

<div>
<Title order={4}>{t('archiveSettings.folderTemplateText')}</Title>
<Title mt={10} order={4}>{t('archiveSettings.folderTemplateText')}</Title>

<Textarea
description="{{uuid}} is required to be present for the folder template."
Expand All @@ -216,19 +237,24 @@ const AdminSettingsPage = () => {
</div>

<div>
<Title mt={5} order={4}>
<Title mt={10} order={4}>
Available Variables
</Title>

<div>
<Text>Ganymede</Text>
<Text fw={600}>Channel (all templates)</Text>
<ul>
<li><Code>{"{{channel}}"}</Code>: Channel login name (lowercase username)</li>
<li><Code>{"{{channel_id}}"}</Code>: External platform ID (e.g., Twitch User ID) - stable, never changes</li>
<li><Code>{"{{channel_display_name}}"}</Code>: Channel display name</li>
</ul>
<Text fw={600}>Ganymede (folder &amp; file templates only)</Text>
<ul>
<li><Code>{"{{uuid}}"}</Code>: Unique identifier for the archive</li>
</ul>
<Text>Video</Text>
<Text fw={600}>Video (folder &amp; file templates only)</Text>
<ul>
<li><Code>{"{{id}}"}</Code>: Video ID</li>
<li><Code>{"{{channel}}"}</Code>: Channel name</li>
<li><Code>{"{{title}}"}</Code>: Video title (file safe)</li>
<li><Code>{"{{type}}"}</Code>: Video type (live, archive, highlight)</li>
<li><Code>{"{{date}}"}</Code>: Formatted date (YYYY-MM-DD)</li>
Expand All @@ -237,7 +263,6 @@ const AdminSettingsPage = () => {
<li><Code>{"{{DD}}"}</Code>: Day</li>
<li><Code>{"{{HH}}"}</Code>: Hour</li>
</ul>

</div>
</div>

Expand All @@ -246,10 +271,14 @@ const AdminSettingsPage = () => {
{t('archiveSettings.examplesText')}
</Title>

<Text>Folder</Text>
<Text>Channel Folder</Text>
<Code block>{"{{channel}}"}</Code>
<Code block>{"{{channel_id}}"}</Code>
<Code block>{"{{channel_id}}_{{channel}}"}</Code>
<Text mt={5}>VOD Folder</Text>
<Code block>{folderExample1}</Code>
<Code block>{folderExample2}</Code>
<Text>File</Text>
<Text mt={5}>File</Text>
<Code block>{fileExample1}</Code>
</div>

Expand Down
1 change: 1 addition & 0 deletions frontend/app/hooks/useConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface Config {
export interface StorageTemplate {
folder_template: string;
file_template: string;
channel_folder_template: string;
}

export enum ProxyType {
Expand Down
122 changes: 86 additions & 36 deletions internal/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,25 @@ func (s *Service) ArchiveChannel(ctx context.Context, channelName string) (*ent.
return nil, fmt.Errorf("channel already exists")
}

// Resolve channel folder name from template
channelFolderName, err := GetChannelFolderName(ChannelTemplateInput{
ChannelName: platformChannel.Login,
ChannelID: platformChannel.ID,
ChannelDisplayName: platformChannel.DisplayName,
})
if err != nil {
log.Warn().Err(err).Msg("error resolving channel folder template, falling back to channel login name")
channelFolderName = platformChannel.Login
}

// Create channel folder
err = utils.CreateDirectory(fmt.Sprintf("%s/%s", env.VideosDir, platformChannel.Login))
err = utils.CreateDirectory(fmt.Sprintf("%s/%s", env.VideosDir, channelFolderName))
if err != nil {
return nil, fmt.Errorf("error creating channel folder: %v", err)
}

// Download channel profile image
err = utils.DownloadFile(platformChannel.ProfileImageURL, fmt.Sprintf("%s/%s/%s", env.VideosDir, platformChannel.Login, "profile.png"))
err = utils.DownloadFile(platformChannel.ProfileImageURL, fmt.Sprintf("%s/%s/%s", env.VideosDir, channelFolderName, "profile.png"))
if err != nil {
log.Error().Err(err).Msg("error downloading channel profile image")
}
Expand All @@ -80,7 +91,7 @@ func (s *Service) ArchiveChannel(ctx context.Context, channelName string) (*ent.
ExtID: platformChannel.ID,
Name: platformChannel.Login,
DisplayName: platformChannel.DisplayName,
ImagePath: fmt.Sprintf("%s/%s/profile.png", env.VideosDir, platformChannel.Login),
ImagePath: fmt.Sprintf("%s/%s/profile.png", env.VideosDir, channelFolderName),
}

dbC, err := s.ChannelService.CreateChannel(channelDTO)
Expand Down Expand Up @@ -156,17 +167,30 @@ func (s *Service) ArchiveVideo(ctx context.Context, input ArchiveVideoInput) (*A
return nil, fmt.Errorf("error creating vod uuid: %v", err)
}

// Resolve channel folder name from template
channelFolderName, err := GetChannelFolderName(ChannelTemplateInput{
ChannelName: channel.Name,
ChannelID: channel.ExtID,
ChannelDisplayName: channel.DisplayName,
})
if err != nil {
log.Warn().Err(err).Msg("error resolving channel folder template, falling back to channel login name")
channelFolderName = channel.Name
}

storageTemplateInput := StorageTemplateInput{
UUID: vUUID,
ID: input.VideoId,
Channel: channel.Name,
Title: video.Title,
Type: video.Type,
Date: video.CreatedAt.Format("2006-01-02"),
YYYY: video.CreatedAt.Format("2006"),
MM: video.CreatedAt.Format("01"),
DD: video.CreatedAt.Format("02"),
HH: video.CreatedAt.Format("15"),
UUID: vUUID,
ID: input.VideoId,
Channel: channel.Name,
ChannelID: channel.ExtID,
ChannelDisplayName: channel.DisplayName,
Title: video.Title,
Type: video.Type,
Date: video.CreatedAt.Format("2006-01-02"),
YYYY: video.CreatedAt.Format("2006"),
MM: video.CreatedAt.Format("01"),
DD: video.CreatedAt.Format("02"),
HH: video.CreatedAt.Format("15"),
}
// Create directory paths
folderName, err := GetFolderName(vUUID, storageTemplateInput)
Expand All @@ -181,7 +205,7 @@ func (s *Service) ArchiveVideo(ctx context.Context, input ArchiveVideoInput) (*A
}

// set facts
rootVideoPath := fmt.Sprintf("%s/%s/%s", envConfig.VideosDir, video.UserLogin, folderName)
rootVideoPath := fmt.Sprintf("%s/%s/%s", envConfig.VideosDir, channelFolderName, folderName)
chatPath := ""
chatVideoPath := ""
liveChatPath := ""
Expand Down Expand Up @@ -355,17 +379,30 @@ func (s *Service) ArchiveClip(ctx context.Context, input ArchiveClipInput) (*Arc
return nil, fmt.Errorf("error creating vod uuid: %v", err)
}

// Resolve channel folder name from template
channelFolderName, err := GetChannelFolderName(ChannelTemplateInput{
ChannelName: channel.Name,
ChannelID: channel.ExtID,
ChannelDisplayName: channel.DisplayName,
})
if err != nil {
log.Warn().Err(err).Msg("error resolving channel folder template, falling back to channel login name")
channelFolderName = channel.Name
}

storageTemplateInput := StorageTemplateInput{
UUID: vUUID,
ID: clip.ID,
Channel: channel.Name,
Title: clip.Title,
Type: string(utils.Clip),
Date: clip.CreatedAt.Format("2006-01-02"),
YYYY: clip.CreatedAt.Format("2006"),
MM: clip.CreatedAt.Format("01"),
DD: clip.CreatedAt.Format("02"),
HH: clip.CreatedAt.Format("15"),
UUID: vUUID,
ID: clip.ID,
Channel: channel.Name,
ChannelID: channel.ExtID,
ChannelDisplayName: channel.DisplayName,
Title: clip.Title,
Type: string(utils.Clip),
Date: clip.CreatedAt.Format("2006-01-02"),
YYYY: clip.CreatedAt.Format("2006"),
MM: clip.CreatedAt.Format("01"),
DD: clip.CreatedAt.Format("02"),
HH: clip.CreatedAt.Format("15"),
}
// Create directory paths
folderName, err := GetFolderName(vUUID, storageTemplateInput)
Expand All @@ -380,7 +417,7 @@ func (s *Service) ArchiveClip(ctx context.Context, input ArchiveClipInput) (*Arc
}

// set facts
rootVideoPath := fmt.Sprintf("%s/%s/%s", envConfig.VideosDir, channel.Name, folderName)
rootVideoPath := fmt.Sprintf("%s/%s/%s", envConfig.VideosDir, channelFolderName, folderName)
chatPath := ""
chatVideoPath := ""
liveChatPath := ""
Expand Down Expand Up @@ -518,17 +555,30 @@ func (s *Service) ArchiveLivestream(ctx context.Context, input ArchiveVideoInput
return nil, fmt.Errorf("error creating vod uuid: %v", err)
}

// Resolve channel folder name from template
channelFolderName, err := GetChannelFolderName(ChannelTemplateInput{
ChannelName: channel.Name,
ChannelID: channel.ExtID,
ChannelDisplayName: channel.DisplayName,
})
if err != nil {
log.Warn().Err(err).Msg("error resolving channel folder template, falling back to channel login name")
channelFolderName = channel.Name
}

storageTemplateInput := StorageTemplateInput{
UUID: vUUID,
ID: video.ID,
Channel: channel.Name,
Title: video.Title,
Type: video.Type,
Date: video.StartedAt.Format("2006-01-02"),
YYYY: video.StartedAt.Format("2006"),
MM: video.StartedAt.Format("01"),
DD: video.StartedAt.Format("02"),
HH: video.StartedAt.Format("15"),
UUID: vUUID,
ID: video.ID,
Channel: channel.Name,
ChannelID: channel.ExtID,
ChannelDisplayName: channel.DisplayName,
Title: video.Title,
Type: video.Type,
Date: video.StartedAt.Format("2006-01-02"),
YYYY: video.StartedAt.Format("2006"),
MM: video.StartedAt.Format("01"),
DD: video.StartedAt.Format("02"),
HH: video.StartedAt.Format("15"),
}
// Create directory paths
folderName, err := GetFolderName(vUUID, storageTemplateInput)
Expand All @@ -543,7 +593,7 @@ func (s *Service) ArchiveLivestream(ctx context.Context, input ArchiveVideoInput
}

// set facts
rootVideoPath := fmt.Sprintf("%s/%s/%s", envConfig.VideosDir, video.UserLogin, folderName)
rootVideoPath := fmt.Sprintf("%s/%s/%s", envConfig.VideosDir, channelFolderName, folderName)
chatPath := ""
chatVideoPath := ""
liveChatPath := ""
Expand Down
Loading
Loading